├── testdata ├── monorepo │ ├── bar │ │ ├── go.mod │ │ ├── .golangci.yaml │ │ └── main.go │ └── foo │ │ ├── go.mod │ │ ├── .golangci.yaml │ │ └── main.go ├── loadconfig │ ├── go.mod │ ├── main.go │ └── .golangci.yaml ├── multifile │ ├── go.mod │ ├── bar.go │ └── main.go ├── nesteddir │ ├── go.mod │ ├── main.go │ └── bar │ │ └── bar.go ├── noconfig │ ├── go.mod │ └── main.go └── nolintername │ ├── go.mod │ └── main.go ├── Makefile ├── go.mod ├── go.sum ├── .github └── workflows │ ├── release.yaml │ └── go.yaml ├── .goreleaser.yaml ├── uri.go ├── logger.go ├── LICENSE ├── main.go ├── golangci-lint.go ├── lsp.go ├── README.md ├── handler.go └── handler_test.go /testdata/monorepo/bar/go.mod: -------------------------------------------------------------------------------- 1 | module foo 2 | 3 | go 1.22.10 4 | -------------------------------------------------------------------------------- /testdata/monorepo/foo/go.mod: -------------------------------------------------------------------------------- 1 | module foo 2 | 3 | go 1.22.10 4 | -------------------------------------------------------------------------------- /testdata/loadconfig/go.mod: -------------------------------------------------------------------------------- 1 | module loadconfig 2 | 3 | go 1.22.10 4 | -------------------------------------------------------------------------------- /testdata/multifile/go.mod: -------------------------------------------------------------------------------- 1 | module multifile 2 | 3 | go 1.22.10 4 | -------------------------------------------------------------------------------- /testdata/nesteddir/go.mod: -------------------------------------------------------------------------------- 1 | module nesteddir 2 | 3 | go 1.22.10 4 | -------------------------------------------------------------------------------- /testdata/noconfig/go.mod: -------------------------------------------------------------------------------- 1 | module noconfig 2 | 3 | go 1.22.10 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | @go test ./... 3 | 4 | install: 5 | @go install 6 | -------------------------------------------------------------------------------- /testdata/nolintername/go.mod: -------------------------------------------------------------------------------- 1 | module nolintername 2 | 3 | go 1.22.10 4 | -------------------------------------------------------------------------------- /testdata/nolintername/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // var `foo` is unused 4 | var foo = "foo" 5 | -------------------------------------------------------------------------------- /testdata/multifile/bar.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // unused: var `bar` is unused 4 | var bar = "bar" 5 | -------------------------------------------------------------------------------- /testdata/multifile/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // unused: var `foo` is unused 4 | var foo = "foo" 5 | -------------------------------------------------------------------------------- /testdata/nesteddir/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // unused: var `foo` is unused 4 | var foo = "foo" 5 | -------------------------------------------------------------------------------- /testdata/noconfig/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // unused: var `foo` is unused 4 | var foo = "foo" 5 | -------------------------------------------------------------------------------- /testdata/nesteddir/bar/bar.go: -------------------------------------------------------------------------------- 1 | package bar 2 | 3 | // unused: var `bar` is unused 4 | var bar = "bar" 5 | -------------------------------------------------------------------------------- /testdata/monorepo/bar/.golangci.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | enable: 4 | - unused 5 | - wsl 6 | -------------------------------------------------------------------------------- /testdata/monorepo/foo/.golangci.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | enable: 4 | - wsl 5 | disable: 6 | - unused 7 | -------------------------------------------------------------------------------- /testdata/monorepo/bar/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | var bar = "bar" 4 | var foo = "foo" 5 | 6 | func Bar() { 7 | _ = bar 8 | 9 | } 10 | -------------------------------------------------------------------------------- /testdata/monorepo/foo/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | var bar = "bar" 4 | var foo = "foo" 5 | 6 | func Bar() { 7 | _ = bar 8 | 9 | } 10 | -------------------------------------------------------------------------------- /testdata/loadconfig/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // unused: var `foo` is unused 4 | var foo = "foo" 5 | 6 | func Bar() { 7 | _ = foo 8 | 9 | } 10 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nametake/golangci-lint-langserver 2 | 3 | go 1.23.4 4 | 5 | require github.com/sourcegraph/jsonrpc2 v0.2.0 6 | 7 | require github.com/google/go-cmp v0.6.0 8 | -------------------------------------------------------------------------------- /testdata/loadconfig/.golangci.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | enable: 4 | - wsl 5 | disable: 6 | - unused 7 | exclusions: 8 | generated: lax 9 | presets: 10 | - comments 11 | - common-false-positives 12 | - legacy 13 | - std-error-handling 14 | paths: 15 | - third_party$ 16 | - builtin$ 17 | - examples$ 18 | formatters: 19 | exclusions: 20 | generated: lax 21 | paths: 22 | - third_party$ 23 | - builtin$ 24 | - examples$ 25 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 2 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 3 | github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM= 4 | github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 5 | github.com/sourcegraph/jsonrpc2 v0.2.0 h1:KjN/dC4fP6aN9030MZCJs9WQbTOjWHhrtKVpzzSrr/U= 6 | github.com/sourcegraph/jsonrpc2 v0.2.0/go.mod h1:ZafdZgk/axhT1cvZAPOhw+95nz2I/Ra5qMlU4gTRwIo= 7 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release 3 | on: 4 | push: 5 | tags: 6 | - 'v[0-9]+.[0-9]+.[0-9]+' 7 | permissions: 8 | contents: write 9 | jobs: 10 | goreleaser: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | - name: Setup Go 18 | uses: actions/setup-go@v4 19 | with: 20 | go-version: stable 21 | - name: Run GoReleaser 22 | uses: goreleaser/goreleaser-action@v5 23 | with: 24 | distribution: goreleaser 25 | version: latest 26 | args: release --clean 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | project_name: golangci-lint-langserver 3 | env: 4 | - GO111MODULE=on 5 | before: 6 | hooks: 7 | - go mod tidy 8 | builds: 9 | - main: . 10 | binary: golangci-lint-langserver 11 | ldflags: 12 | - -s -w 13 | - -X main.Version={{.Version}} 14 | - -X main.Revision={{.ShortCommit}} 15 | env: 16 | - CGO_ENABLED=0 17 | archives: 18 | - name_template: >- 19 | {{- .ProjectName }}_ 20 | {{- title .Os }}_ 21 | {{- if eq .Arch "amd64" }}x86_64 22 | {{- else if eq .Arch "386" }}i386 23 | {{- else }}{{ .Arch }}{{ end }} 24 | {{- if .Arm }}v{{ .Arm }}{{ end -}} 25 | format_overrides: 26 | - goos: windows 27 | format: zip 28 | release: 29 | prerelease: auto 30 | -------------------------------------------------------------------------------- /uri.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/url" 5 | "path/filepath" 6 | "strings" 7 | "unicode" 8 | ) 9 | 10 | func uriToPath(uri string) string { 11 | switch { 12 | case strings.HasPrefix(uri, "file:///"): 13 | uri = uri[len("file://"):] 14 | case strings.HasPrefix(uri, "file://"): 15 | uri = uri[len("file:/"):] 16 | } 17 | 18 | if path, err := url.PathUnescape(uri); err == nil { 19 | uri = path 20 | } 21 | 22 | if isWindowsDriveURIPath(uri) { 23 | uri = strings.ToUpper(string(uri[1])) + uri[2:] 24 | } 25 | 26 | return filepath.FromSlash(uri) 27 | } 28 | 29 | func isWindowsDriveURIPath(uri string) bool { 30 | if len(uri) < 4 { 31 | return false 32 | } 33 | 34 | return uri[0] == '/' && unicode.IsLetter(rune(uri[1])) && uri[2] == ':' 35 | } 36 | -------------------------------------------------------------------------------- /logger.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | ) 7 | 8 | var _ logger = (*stdLogger)(nil) 9 | 10 | type logger interface { 11 | Printf(format string, args ...any) 12 | DebugJSON(label string, arg any) 13 | } 14 | 15 | type stdLogger struct { 16 | debug bool 17 | stderr *log.Logger 18 | } 19 | 20 | func newStdLogger(debug bool) *stdLogger { 21 | return &stdLogger{ 22 | debug: debug, 23 | stderr: log.New(log.Writer(), "", log.LstdFlags), 24 | } 25 | } 26 | 27 | func (l *stdLogger) Printf(format string, args ...any) { 28 | l.stderr.Printf(format, args...) 29 | } 30 | 31 | func (l *stdLogger) DebugJSON(label string, arg any) { 32 | if !l.debug { 33 | return 34 | } 35 | 36 | b, err := json.Marshal(arg) 37 | if err != nil { 38 | l.stderr.Println(err) 39 | return 40 | } 41 | 42 | l.stderr.Println(label, string(b)) 43 | } 44 | -------------------------------------------------------------------------------- /.github/workflows/go.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Go 3 | on: 4 | push: 5 | jobs: 6 | vet: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: actions/setup-go@v5 11 | - name: Run go vet 12 | run: go vet ./... 13 | test: 14 | strategy: 15 | matrix: 16 | os: [ubuntu-latest, windows-latest] 17 | runs-on: ${{ matrix.os }} 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: actions/setup-go@v5 21 | - name: Install golangci-lint 22 | run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest 23 | - name: Run test 24 | run: go test ./... 25 | golangci-lint: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v4 29 | - uses: actions/setup-go@v5 30 | - name: Install golangci-lint 31 | run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest 32 | - name: Run golangci-lint 33 | run: golangci-lint run 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Shogo NAMEKI 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "os" 7 | 8 | "github.com/sourcegraph/jsonrpc2" 9 | ) 10 | 11 | var defaultSeverity = "Warn" 12 | 13 | func main() { 14 | debug := flag.Bool("debug", false, "output debug log") 15 | noLinterName := flag.Bool("nolintername", false, "don't show a linter name in message") 16 | flag.StringVar(&defaultSeverity, "severity", defaultSeverity, "Default severity to use. Choices are: Err(or), Warn(ing), Info(rmation) or Hint") 17 | 18 | flag.Parse() 19 | 20 | logger := newStdLogger(*debug) 21 | 22 | handler := NewHandler(logger, *noLinterName) 23 | 24 | var connOpt []jsonrpc2.ConnOpt 25 | 26 | logger.Printf("golangci-lint-langserver: connections opened") 27 | 28 | <-jsonrpc2.NewConn( 29 | context.Background(), 30 | jsonrpc2.NewBufferedStream(stdrwc{}, jsonrpc2.VSCodeObjectCodec{}), 31 | handler, 32 | connOpt..., 33 | ).DisconnectNotify() 34 | 35 | logger.Printf("golangci-lint-langserver: connections closed") 36 | } 37 | 38 | type stdrwc struct{} 39 | 40 | func (stdrwc) Read(p []byte) (int, error) { 41 | return os.Stdin.Read(p) 42 | } 43 | 44 | func (stdrwc) Write(p []byte) (int, error) { 45 | return os.Stdout.Write(p) 46 | } 47 | 48 | func (stdrwc) Close() error { 49 | if err := os.Stdin.Close(); err != nil { 50 | return err 51 | } 52 | 53 | return os.Stdout.Close() 54 | } 55 | -------------------------------------------------------------------------------- /golangci-lint.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "strings" 4 | 5 | type Issue struct { 6 | FromLinter string `json:"FromLinter"` 7 | Text string `json:"Text"` 8 | Severity string `json:"Severity"` 9 | SourceLines []string `json:"SourceLines"` 10 | Replacement any `json:"Replacement"` 11 | Pos struct { 12 | Filename string `json:"Filename"` 13 | Offset int `json:"Offset"` 14 | Line int `json:"Line"` 15 | Column int `json:"Column"` 16 | } `json:"Pos"` 17 | ExpectNoLint bool `json:"ExpectNoLint"` 18 | ExpectedNoLintLinter string `json:"ExpectedNoLintLinter"` 19 | LineRange struct { 20 | From int `json:"From"` 21 | To int `json:"To"` 22 | } `json:"LineRange,omitempty"` 23 | } 24 | 25 | func (i Issue) DiagSeverity() DiagnosticSeverity { 26 | if i.Severity == "" { 27 | // TODO: How to get default-severity from .golangci.yml, if available? 28 | i.Severity = defaultSeverity 29 | } 30 | 31 | switch strings.ToLower(i.Severity) { 32 | case "err", "error": 33 | return DSError 34 | case "warn", "warning": 35 | return DSWarning 36 | case "info", "information": 37 | return DSInformation 38 | case "hint": 39 | return DSHint 40 | default: 41 | return DSWarning 42 | } 43 | } 44 | 45 | type GolangCILintResult struct { 46 | Issues []Issue `json:"Issues"` 47 | Report struct { 48 | Linters []struct { 49 | Name string `json:"Name"` 50 | Enabled bool `json:"Enabled"` 51 | EnabledByDefault bool `json:"EnabledByDefault,omitempty"` 52 | } `json:"Linters"` 53 | Error string `json:"Error"` 54 | } `json:"Report"` 55 | } 56 | -------------------------------------------------------------------------------- /lsp.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | type DocumentURI string 4 | 5 | type InitializeParams struct { 6 | RootURI string `json:"rootUri,omitempty"` 7 | InitializationOptions InitializationOptions `json:"initializationOptions,omitempty"` 8 | } 9 | 10 | type InitializationOptions struct { 11 | Command []string 12 | } 13 | 14 | type InitializeResult struct { 15 | Capabilities ServerCapabilities `json:"capabilities,omitempty"` 16 | } 17 | 18 | type TextDocumentSyncKind int 19 | 20 | const ( 21 | TDSKNone TextDocumentSyncKind = iota 22 | TDSKFull 23 | TDSKIncremental 24 | ) 25 | 26 | type CompletionProvider struct { 27 | ResolveProvider bool `json:"resolveProvider,omitempty"` 28 | TriggerCharacters []string `json:"triggerCharacters"` 29 | } 30 | 31 | type TextDocumentSyncOptions struct { 32 | OpenClose bool `json:"openClose,omitempty"` 33 | Change TextDocumentSyncKind `json:"change,omitempty"` 34 | WillSave bool `json:"willSave,omitempty"` 35 | WillSaveWaitUntil bool `json:"willSaveWaitUntil,omitempty"` 36 | Save bool `json:"save,omitempty"` 37 | } 38 | 39 | type ServerCapabilities struct { 40 | TextDocumentSync TextDocumentSyncOptions `json:"textDocumentSync,omitempty"` 41 | CompletionProvider *CompletionProvider `json:"completionProvider,omitempty"` 42 | DocumentSymbolProvider bool `json:"documentSymbolProvider,omitempty"` 43 | DefinitionProvider bool `json:"definitionProvider,omitempty"` 44 | DocumentFormattingProvider bool `json:"documentFormattingProvider,omitempty"` 45 | HoverProvider bool `json:"hoverProvider,omitempty"` 46 | CodeActionProvider bool `json:"codeActionProvider,omitempty"` 47 | } 48 | 49 | type TextDocumentItem struct { 50 | URI DocumentURI `json:"uri"` 51 | LanguageID string `json:"languageId"` 52 | Version int `json:"version"` 53 | Text string `json:"text"` 54 | } 55 | 56 | type TextDocumentIdentifier struct { 57 | URI DocumentURI `json:"uri"` 58 | } 59 | 60 | type DidOpenTextDocumentParams struct { 61 | TextDocument TextDocumentItem `json:"textDocument"` 62 | } 63 | 64 | type DidSaveTextDocumentParams struct { 65 | Text *string `json:"text"` 66 | TextDocument TextDocumentIdentifier `json:"textDocument"` 67 | } 68 | 69 | type Location struct { 70 | URI string `json:"uri"` 71 | Range Range `json:"range"` 72 | } 73 | 74 | type Range struct { 75 | Start Position `json:"start"` 76 | End Position `json:"end"` 77 | } 78 | 79 | type Position struct { 80 | Line int `json:"line"` 81 | Character int `json:"character"` 82 | } 83 | 84 | type DiagnosticRelatedInformation struct { 85 | Location Location `json:"location"` 86 | Message string `json:"message"` 87 | } 88 | 89 | type DiagnosticSeverity int 90 | 91 | const ( 92 | DSError DiagnosticSeverity = iota + 1 93 | DSWarning 94 | DSInformation 95 | DSHint 96 | ) 97 | 98 | type Diagnostic struct { 99 | Range Range `json:"range"` 100 | Severity DiagnosticSeverity `json:"severity,omitempty"` 101 | Code *string `json:"code,omitempty"` 102 | Source *string `json:"source,omitempty"` 103 | Message string `json:"message"` 104 | RelatedInformation []DiagnosticRelatedInformation `json:"relatedInformation,omitempty"` 105 | } 106 | 107 | type PublishDiagnosticsParams struct { 108 | URI DocumentURI `json:"uri"` 109 | Diagnostics []Diagnostic `json:"diagnostics"` 110 | } 111 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # golangci-lint-langserver 2 | 3 | golangci-lint-langserver is [golangci-lint](https://github.com/golangci/golangci-lint) language server. 4 | 5 | [![asciicast](https://asciinema.org/a/308369.svg)](https://asciinema.org/a/308369) 6 | 7 | 8 | ## Installation 9 | 10 | Install [golangci-lint](https://golangci-lint.run). 11 | 12 | ```console 13 | go install github.com/nametake/golangci-lint-langserver@latest 14 | ``` 15 | 16 | ## Options 17 | 18 | ```console 19 | -debug 20 | output debug log 21 | -nolintername 22 | don't show a linter name in message 23 | ``` 24 | 25 | ## Configuration 26 | 27 | You need to set golangci-lint command to initializationOptions with `--out-format json`. 28 | 29 | ### Configuration for [coc.nvim](https://github.com/neoclide/coc.nvim) 30 | 31 | coc-settings.json 32 | 33 | ```jsonc 34 | { 35 | "languageserver": { 36 | "golangci-lint-languageserver": { 37 | "command": "golangci-lint-langserver", 38 | "filetypes": ["go"], 39 | "initializationOptions": { 40 | "command": ["golangci-lint", "run", "--output.json.path", "stdout", "--show-stats=false", "--issues-exit-code=1"] 41 | } 42 | } 43 | } 44 | ``` 45 | 46 | ### Configuration for [vim-lsp](https://github.com/prabirshrestha/vim-lsp) 47 | 48 | ```vim 49 | augroup vim_lsp_golangci_lint_langserver 50 | au! 51 | autocmd User lsp_setup call lsp#register_server({ 52 | \ 'name': 'golangci-lint-langserver', 53 | \ 'cmd': {server_info->['golangci-lint-langserver']}, 54 | \ 'initialization_options': {'command': ['golangci-lint', 'run', '--output.json.path', 'stdout', '--show-stats=false', '--issues-exit-code=1']}, 55 | \ 'whitelist': ['go'], 56 | \ }) 57 | augroup END 58 | ``` 59 | 60 | [vim-lsp-settings](https://github.com/mattn/vim-lsp-settings) provide installer for golangci-lint-langserver. 61 | 62 | ### Configuration for [nvim-lspconfig](https://github.com/neovim/nvim-lspconfig) 63 | 64 | **Requires Neovim [v0.6.1](https://github.com/neovim/neovim/releases/tag/v0.6.1) or [nightly](https://github.com/neovim/neovim/releases/tag/nightly).** 65 | 66 | ```lua 67 | local lspconfig = require 'lspconfig' 68 | local configs = require 'lspconfig/configs' 69 | 70 | if not configs.golangcilsp then 71 | configs.golangcilsp = { 72 | default_config = { 73 | cmd = {'golangci-lint-langserver'}, 74 | root_dir = lspconfig.util.root_pattern('.git', 'go.mod'), 75 | init_options = { 76 | command = { "golangci-lint", "run", "--output.json.path", "stdout", "--show-stats=false", "--issues-exit-code=1" }; 77 | }; 78 | } 79 | end 80 | lspconfig.golangci_lint_ls.setup { 81 | filetypes = {'go','gomod'} 82 | } 83 | ``` 84 | 85 | ### Configuration for [lsp-mode](https://github.com/emacs-lsp/lsp-mode) (Emacs) 86 | 87 | Support for golangci-lint-langserver is 88 | [built-in](https://github.com/emacs-lsp/lsp-mode/blob/master/clients/lsp-golangci-lint.el) 89 | to lsp-mode since late 2023. When the `golangci-lint-langserver` executable is 90 | found, it is automatically started for Go buffers as an add-on server along with 91 | the `gopls` language server. 92 | 93 | ### Configuration for [helix](https://helix-editor.com/) 94 | 95 | You can use `.golangci.yaml` in the project root directory to enable other [linters](https://golangci-lint.run/usage/linters/) 96 | 97 | ```toml 98 | [[language]] 99 | name = "go" 100 | auto-format = true 101 | language-servers = [ "gopls", "golangci-lint-lsp" ] 102 | 103 | [language-server.golangci-lint-lsp] 104 | command = "golangci-lint-langserver" 105 | 106 | [language-server.golangci-lint-lsp.config] 107 | command = ["golangci-lint", "run", "--output.json.path", "stdout", "--show-stats=false", "--issues-exit-code=1"] 108 | ``` 109 | 110 | ## golangci-lint Version Compatibility 111 | 112 | - For golangci-lint v2+: Use `--output.json.path stdout --show-stats=false` parameters 113 | - For golangci-lint v1: Use `--out-format json` parameter 114 | -------------------------------------------------------------------------------- /handler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "os/exec" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/sourcegraph/jsonrpc2" 12 | ) 13 | 14 | func NewHandler(logger logger, noLinterName bool) jsonrpc2.Handler { 15 | handler := &langHandler{ 16 | logger: logger, 17 | request: make(chan DocumentURI), 18 | noLinterName: noLinterName, 19 | } 20 | go handler.linter() 21 | 22 | return jsonrpc2.HandlerWithError(handler.handle) 23 | } 24 | 25 | type langHandler struct { 26 | logger logger 27 | conn *jsonrpc2.Conn 28 | request chan DocumentURI 29 | command []string 30 | noLinterName bool 31 | 32 | rootURI string 33 | rootDir string 34 | } 35 | 36 | // As defined in the `golangci-lint` source code: 37 | // https://github.com/golangci/golangci-lint/blob/main/pkg/exitcodes/exitcodes.go#L24 38 | const GoNoFilesExitCode = 5 39 | 40 | func (h *langHandler) errToDiagnostics(err error) []Diagnostic { 41 | var message string 42 | switch e := err.(type) { 43 | case *exec.ExitError: 44 | if e.ExitCode() == GoNoFilesExitCode { 45 | return []Diagnostic{} 46 | } 47 | message = string(e.Stderr) 48 | default: 49 | h.logger.DebugJSON("golangci-lint-langserver: errToDiagnostics message", message) 50 | message = e.Error() 51 | } 52 | return []Diagnostic{ 53 | {Severity: DSError, Message: message}, 54 | } 55 | } 56 | 57 | func (h *langHandler) lint(uri DocumentURI) ([]Diagnostic, error) { 58 | diagnostics := make([]Diagnostic, 0) 59 | 60 | path := uriToPath(string(uri)) 61 | dir, file := filepath.Split(path) 62 | 63 | args := make([]string, 0, len(h.command)) 64 | args = append(args, h.command[1:]...) 65 | args = append(args, dir) 66 | cmd := exec.Command(h.command[0], args...) 67 | if strings.HasPrefix(path, h.rootDir) { 68 | cmd.Dir = h.rootDir 69 | file = path[len(h.rootDir)+1:] 70 | } else { 71 | cmd.Dir = dir 72 | } 73 | 74 | h.logger.DebugJSON("golangci-lint-langserver: golingci-lint cmd:", cmd.Args) 75 | 76 | b, err := cmd.Output() 77 | if err == nil { 78 | return diagnostics, nil 79 | } else if len(b) == 0 { 80 | // golangci-lint would output critical error to stderr rather than stdout 81 | // https://github.com/nametake/golangci-lint-langserver/issues/24 82 | return h.errToDiagnostics(err), nil 83 | } 84 | 85 | var result GolangCILintResult 86 | if err := json.Unmarshal(b, &result); err != nil { 87 | return h.errToDiagnostics(err), nil 88 | } 89 | 90 | h.logger.DebugJSON("golangci-lint-langserver: result:", result) 91 | 92 | for _, issue := range result.Issues { 93 | if file != issue.Pos.Filename { 94 | continue 95 | } 96 | 97 | d := Diagnostic{ 98 | Range: Range{ 99 | Start: Position{ 100 | Line: max(issue.Pos.Line-1, 0), 101 | Character: max(issue.Pos.Column-1, 0), 102 | }, 103 | End: Position{ 104 | Line: max(issue.Pos.Line-1, 0), 105 | Character: max(issue.Pos.Column-1, 0), 106 | }, 107 | }, 108 | Severity: issue.DiagSeverity(), 109 | Source: &issue.FromLinter, 110 | Message: h.diagnosticMessage(&issue), 111 | } 112 | diagnostics = append(diagnostics, d) 113 | } 114 | 115 | return diagnostics, nil 116 | } 117 | 118 | func (h *langHandler) diagnosticMessage(issue *Issue) string { 119 | if h.noLinterName { 120 | return issue.Text 121 | } 122 | 123 | return fmt.Sprintf("%s: %s", issue.FromLinter, issue.Text) 124 | } 125 | 126 | func (h *langHandler) linter() { 127 | for { 128 | uri, ok := <-h.request 129 | if !ok { 130 | break 131 | } 132 | 133 | diagnostics, err := h.lint(uri) 134 | if err != nil { 135 | h.logger.Printf("%s\n", err) 136 | 137 | continue 138 | } 139 | 140 | if err := h.conn.Notify( 141 | context.Background(), 142 | "textDocument/publishDiagnostics", 143 | &PublishDiagnosticsParams{ 144 | URI: uri, 145 | Diagnostics: diagnostics, 146 | }); err != nil { 147 | h.logger.Printf("%s\n", err) 148 | } 149 | } 150 | } 151 | 152 | func (h *langHandler) handle(ctx context.Context, conn *jsonrpc2.Conn, req *jsonrpc2.Request) (result any, err error) { 153 | h.logger.DebugJSON("golangci-lint-langserver: request:", req) 154 | 155 | switch req.Method { 156 | case "initialize": 157 | return h.handleInitialize(ctx, conn, req) 158 | case "initialized": 159 | return 160 | case "shutdown": 161 | return h.handleShutdown(ctx, conn, req) 162 | case "textDocument/didOpen": 163 | return h.handleTextDocumentDidOpen(ctx, conn, req) 164 | case "textDocument/didClose": 165 | return h.handleTextDocumentDidClose(ctx, conn, req) 166 | case "textDocument/didChange": 167 | return h.handleTextDocumentDidChange(ctx, conn, req) 168 | case "textDocument/didSave": 169 | return h.handleTextDocumentDidSave(ctx, conn, req) 170 | case "workspace/didChangeConfiguration": 171 | return h.handlerWorkspaceDidChangeConfiguration(ctx, conn, req) 172 | } 173 | 174 | return nil, &jsonrpc2.Error{Code: jsonrpc2.CodeMethodNotFound, Message: fmt.Sprintf("method not supported: %s", req.Method)} 175 | } 176 | 177 | func (h *langHandler) handleInitialize(_ context.Context, conn *jsonrpc2.Conn, req *jsonrpc2.Request) (result any, err error) { 178 | var params InitializeParams 179 | if err := json.Unmarshal(*req.Params, ¶ms); err != nil { 180 | return nil, err 181 | } 182 | 183 | h.rootURI = params.RootURI 184 | h.rootDir = uriToPath(params.RootURI) 185 | h.conn = conn 186 | h.command = params.InitializationOptions.Command 187 | 188 | return InitializeResult{ 189 | Capabilities: ServerCapabilities{ 190 | TextDocumentSync: TextDocumentSyncOptions{ 191 | Change: TDSKNone, 192 | OpenClose: true, 193 | Save: true, 194 | }, 195 | }, 196 | }, nil 197 | } 198 | 199 | func (h *langHandler) handleShutdown(_ context.Context, _ *jsonrpc2.Conn, _ *jsonrpc2.Request) (result any, err error) { 200 | close(h.request) 201 | 202 | return nil, nil 203 | } 204 | 205 | func (h *langHandler) handleTextDocumentDidOpen(_ context.Context, _ *jsonrpc2.Conn, req *jsonrpc2.Request) (result any, err error) { 206 | var params DidOpenTextDocumentParams 207 | if err := json.Unmarshal(*req.Params, ¶ms); err != nil { 208 | return nil, err 209 | } 210 | 211 | h.request <- params.TextDocument.URI 212 | 213 | return nil, nil 214 | } 215 | 216 | func (h *langHandler) handleTextDocumentDidClose(_ context.Context, _ *jsonrpc2.Conn, _ *jsonrpc2.Request) (result any, err error) { 217 | return nil, nil 218 | } 219 | 220 | func (h *langHandler) handleTextDocumentDidChange(_ context.Context, _ *jsonrpc2.Conn, _ *jsonrpc2.Request) (result any, err error) { 221 | return nil, nil 222 | } 223 | 224 | func (h *langHandler) handleTextDocumentDidSave(_ context.Context, _ *jsonrpc2.Conn, req *jsonrpc2.Request) (result any, err error) { 225 | var params DidSaveTextDocumentParams 226 | if err := json.Unmarshal(*req.Params, ¶ms); err != nil { 227 | return nil, err 228 | } 229 | 230 | h.request <- params.TextDocument.URI 231 | 232 | return nil, nil 233 | } 234 | 235 | func (h *langHandler) handlerWorkspaceDidChangeConfiguration(_ context.Context, _ *jsonrpc2.Conn, _ *jsonrpc2.Request) (result any, err error) { 236 | return nil, nil 237 | } 238 | -------------------------------------------------------------------------------- /handler_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os/exec" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | ) 10 | 11 | func pt(s string) *string { 12 | return &s 13 | } 14 | 15 | func TestLangHandler_lint_Integration(t *testing.T) { 16 | if _, err := exec.LookPath("golangci-lint"); err != nil { 17 | t.Fatal("golangci-lint is not installed in this environment") 18 | } 19 | 20 | command := []string{"golangci-lint", "run", "--output.json.path", "stdout", "--issues-exit-code=1", "--show-stats=false"} 21 | 22 | tests := []struct { 23 | name string 24 | h *langHandler 25 | filePath string 26 | want []Diagnostic 27 | }{ 28 | { 29 | name: "no config file", 30 | h: &langHandler{ 31 | logger: newStdLogger(false), 32 | command: command, 33 | rootDir: filepath.Dir("./testdata/noconfig"), 34 | }, 35 | filePath: "./testdata/noconfig/main.go", 36 | want: []Diagnostic{ 37 | { 38 | Range: Range{ 39 | Start: Position{ 40 | Line: 3, 41 | Character: 4, 42 | }, 43 | End: Position{ 44 | Line: 3, 45 | Character: 4, 46 | }, 47 | }, 48 | Severity: DSWarning, 49 | Code: nil, 50 | Source: pt("unused"), 51 | Message: "unused: var foo is unused", 52 | RelatedInformation: nil, 53 | }, 54 | }, 55 | }, 56 | { 57 | name: "nolintername option works as expected", 58 | h: &langHandler{ 59 | logger: newStdLogger(false), 60 | command: command, 61 | rootDir: filepath.Dir("./testdata/nolintername"), 62 | noLinterName: true, 63 | }, 64 | filePath: "./testdata/nolintername/main.go", 65 | want: []Diagnostic{ 66 | { 67 | Range: Range{ 68 | Start: Position{ 69 | Line: 3, 70 | Character: 4, 71 | }, 72 | End: Position{ 73 | Line: 3, 74 | Character: 4, 75 | }, 76 | }, 77 | Severity: DSWarning, 78 | Code: nil, 79 | Source: pt("unused"), 80 | Message: "var foo is unused", 81 | RelatedInformation: nil, 82 | }, 83 | }, 84 | }, 85 | { 86 | name: "config file is loaded successfully", 87 | h: &langHandler{ 88 | logger: newStdLogger(false), 89 | command: command, 90 | rootDir: filepath.Dir("./testdata/loadconfig"), 91 | }, 92 | filePath: "./testdata/loadconfig/main.go", 93 | want: []Diagnostic{ 94 | { 95 | Range: Range{ 96 | Start: Position{ 97 | Line: 8, 98 | Character: 0, 99 | }, 100 | End: Position{ 101 | Line: 8, 102 | Character: 0, 103 | }, 104 | }, 105 | Severity: DSWarning, 106 | Code: nil, 107 | Source: pt("wsl"), 108 | Message: "wsl: block should not end with a whitespace (or comment)", 109 | RelatedInformation: nil, 110 | }, 111 | }, 112 | }, 113 | { 114 | name: "multiple files in rootDir", 115 | h: &langHandler{ 116 | logger: newStdLogger(false), 117 | command: command, 118 | rootDir: filepath.Dir("./testdata/multifile"), 119 | }, 120 | filePath: "./testdata/multifile/bar.go", 121 | want: []Diagnostic{ 122 | { 123 | Range: Range{ 124 | Start: Position{ 125 | Line: 3, 126 | Character: 4, 127 | }, 128 | End: Position{ 129 | Line: 3, 130 | Character: 4, 131 | }, 132 | }, 133 | Severity: DSWarning, 134 | Code: nil, 135 | Source: pt("unused"), 136 | Message: "unused: var bar is unused", 137 | RelatedInformation: nil, 138 | }, 139 | }, 140 | }, 141 | { 142 | name: "nested directories in rootDir", 143 | h: &langHandler{ 144 | logger: newStdLogger(false), 145 | command: command, 146 | rootDir: filepath.Dir("./testdata/nesteddir"), 147 | }, 148 | filePath: "./testdata/nesteddir/bar/bar.go", 149 | want: []Diagnostic{ 150 | { 151 | Range: Range{ 152 | Start: Position{ 153 | Line: 3, 154 | Character: 4, 155 | }, 156 | End: Position{ 157 | Line: 3, 158 | Character: 4, 159 | }, 160 | }, 161 | Severity: DSWarning, 162 | Code: nil, 163 | Source: pt("unused"), 164 | Message: "unused: var bar is unused", 165 | RelatedInformation: nil, 166 | }, 167 | }, 168 | }, 169 | { 170 | name: "monorepo with multiple go.mod and .golangci.yaml files (foo module)", 171 | h: &langHandler{ 172 | logger: newStdLogger(false), 173 | command: command, 174 | rootDir: filepath.Dir("./testdata/monorepo"), 175 | }, 176 | filePath: "./testdata/monorepo/foo/main.go", 177 | want: []Diagnostic{ 178 | { 179 | Range: Range{ 180 | Start: Position{ 181 | Line: 8, 182 | Character: 0, 183 | }, 184 | End: Position{ 185 | Line: 8, 186 | Character: 0, 187 | }, 188 | }, 189 | Severity: DSWarning, 190 | Code: nil, 191 | Source: pt("wsl"), 192 | Message: "wsl: block should not end with a whitespace (or comment)", 193 | RelatedInformation: nil, 194 | }, 195 | }, 196 | }, 197 | { 198 | name: "monorepo with multiple go.mod and .golangci.yaml files (bar module)", 199 | h: &langHandler{ 200 | logger: newStdLogger(false), 201 | command: command, 202 | rootDir: filepath.Dir("./testdata/monorepo"), 203 | }, 204 | filePath: "./testdata/monorepo/bar/main.go", 205 | want: []Diagnostic{ 206 | { 207 | Range: Range{ 208 | Start: Position{ 209 | Line: 3, 210 | Character: 4, 211 | }, 212 | End: Position{ 213 | Line: 3, 214 | Character: 4, 215 | }, 216 | }, 217 | Severity: DSWarning, 218 | Code: nil, 219 | Source: pt("unused"), 220 | Message: "unused: var foo is unused", 221 | RelatedInformation: nil, 222 | }, 223 | { 224 | Range: Range{ 225 | Start: Position{ 226 | Line: 8, 227 | Character: 0, 228 | }, 229 | End: Position{ 230 | Line: 8, 231 | Character: 0, 232 | }, 233 | }, 234 | Severity: DSWarning, 235 | Code: nil, 236 | Source: pt("wsl"), 237 | Message: "wsl: block should not end with a whitespace (or comment)", 238 | RelatedInformation: nil, 239 | }, 240 | }, 241 | }, 242 | } 243 | 244 | for _, tt := range tests { 245 | t.Run(tt.name, func(t *testing.T) { 246 | testFilePath, err := filepath.Abs(tt.filePath) 247 | if err != nil { 248 | t.Fatalf("filepath.Abs() returned unexpected error: %v", err) 249 | } 250 | testURI := DocumentURI("file://" + testFilePath) 251 | diagnostics, err := tt.h.lint(testURI) 252 | if err != nil { 253 | t.Fatalf("lint() returned unexpected error: %v", err) 254 | } 255 | if diff := cmp.Diff(tt.want, diagnostics); diff != "" { 256 | t.Errorf("lint() mismatch (-want +got):\n%s", diff) 257 | } 258 | }) 259 | } 260 | } 261 | --------------------------------------------------------------------------------