Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,25 @@ To check the current directory, run:
tlin .
```

### Analyzing `.gno` packages with `golangci-lint`

The bundled `golangci-lint` rule needs `go/packages` to resolve imports.
Out of the box that fails for `.gno` files because `gno.land/...` paths
are not real Go modules. tlin auto-wires
[`gnopls`](https://github.com/gnoverse/gnopls) as the
`GOPACKAGESDRIVER` when both are available:

```bash
go install github.com/gnoverse/gnopls@latest
export GNOROOT=/path/to/gno
tlin .
```

When `gnopls` or `GNOROOT` is missing tlin falls back to running
`golangci-lint` without a custom driver — pure-`.gno` packages then
produce no `golangci-lint` output, but other rules continue to run
normally.

## Configuration

tlin supports a configuration file (`.tlin.yaml`) to customize its behavior. You can generate a default configuration file by running:
Expand Down
3 changes: 3 additions & 0 deletions internal/lints/default_golangci.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ func (golangciLintRule) CheckPackage(ctx context.Context, pctx *rule.PackageCont
}

cmd := exec.CommandContext(ctx, "golangci-lint", "run", "--config=./.golangci.yml", "--out-format=json", target)
if env := cachedDriverEnv(); env != nil {
cmd.Env = env
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
stdout, _ := cmd.Output()
Expand Down
106 changes: 106 additions & 0 deletions internal/lints/gnopls_driver.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package lints

import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"sync"
)

type driverDeps struct {
lookPath func(string) (string, error)
getenv func(string) string
ensureShim func(gnoplsPath string) (string, error)
}

func defaultDriverDeps() driverDeps {
return driverDeps{
lookPath: exec.LookPath,
getenv: os.Getenv,
ensureShim: func(gnoplsPath string) (string, error) {
return cachedGnoplsShim(os.TempDir(), runtime.GOOS, os.Getpid(), gnoplsPath)
},
}
}

// gnoDriverEnv routes go/packages.Load (used internally by
// golangci-lint) through gnopls so gno.land/... import paths
// resolve. Returns nil when gnopls or GNOROOT is missing — callers
// fall back to plain golangci-lint, which produces empty stdout on
// pure-gno packages and is already handled as "no issues".
func gnoDriverEnv(d driverDeps) []string {
gnoplsPath, err := d.lookPath("gnopls")
if err != nil {
return nil
}
gnoroot := d.getenv("GNOROOT")
if gnoroot == "" {
return nil
}
shim, err := d.ensureShim(gnoplsPath)
if err != nil {
return nil
}
env := []string{
"GOPACKAGESDRIVER=" + shim,
"GNOROOT=" + gnoroot,
}
if v := d.getenv("GNOBUILTIN"); v != "" {
env = append(env, "GNOBUILTIN="+v)
}
return env
}

var (
shimOnce sync.Once
shimPath string
shimErr error

envOnce sync.Once
envCache []string
)

// cachedDriverEnv returns the full env to hand to the golangci-lint
// subprocess (process env + driver overrides), or nil when gnopls
// is unavailable and no override is needed. Detection involves
// PATH traversal, shim creation, and an os.Environ() snapshot —
// all of which would otherwise repeat per CheckPackage call.
func cachedDriverEnv() []string {
envOnce.Do(func() {
extra := gnoDriverEnv(defaultDriverDeps())
if extra == nil {
return
}
envCache = append(os.Environ(), extra...)
})
return envCache
}

// cachedGnoplsShim writes the GOPACKAGESDRIVER shim once per
// process. GOPACKAGESDRIVER is consumed as a single executable
// path, so wrapping `gnopls resolve` requires a script.
func cachedGnoplsShim(tempDir, goos string, pid int, gnoplsPath string) (string, error) {
shimOnce.Do(func() {
shimPath, shimErr = writeGnoplsShim(tempDir, goos, pid, gnoplsPath)
})
return shimPath, shimErr
}

func writeGnoplsShim(tempDir, goos string, pid int, gnoplsPath string) (string, error) {
if goos == "windows" {
path := filepath.Join(tempDir, fmt.Sprintf("tlin-gnopls-driver-%d.cmd", pid))
body := fmt.Sprintf("@echo off\r\n\"%s\" resolve %%*\r\n", gnoplsPath)
if err := os.WriteFile(path, []byte(body), 0o700); err != nil {
return "", err
}
return path, nil
}
path := filepath.Join(tempDir, fmt.Sprintf("tlin-gnopls-driver-%d", pid))
body := fmt.Sprintf("#!/bin/sh\nexec %q resolve \"$@\"\n", gnoplsPath)
if err := os.WriteFile(path, []byte(body), 0o700); err != nil {
return "", err
}
return path, nil
}
122 changes: 122 additions & 0 deletions internal/lints/gnopls_driver_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package lints

import (
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestGnoDriverEnv(t *testing.T) {
t.Parallel()

missingBinary := func(string) (string, error) { return "", errors.New("not found") }
binaryAt := func(name string) func(string) (string, error) {
return func(string) (string, error) { return name, nil }
}
envFrom := func(m map[string]string) func(string) string {
return func(k string) string { return m[k] }
}
stubShim := func(want string) func(string) (string, error) {
return func(string) (string, error) { return want, nil }
}
failingShim := func(string) (string, error) { return "", errors.New("disk full") }

t.Run("returns nil when gnopls is not on PATH", func(t *testing.T) {
t.Parallel()
env := gnoDriverEnv(driverDeps{
lookPath: missingBinary,
getenv: envFrom(map[string]string{"GNOROOT": "/gno"}),
ensureShim: stubShim("/never/used"),
})
assert.Nil(t, env)
})

t.Run("returns nil when GNOROOT is unset", func(t *testing.T) {
t.Parallel()
env := gnoDriverEnv(driverDeps{
lookPath: binaryAt("/usr/bin/gnopls"),
getenv: envFrom(nil),
ensureShim: stubShim("/never/used"),
})
assert.Nil(t, env)
})

t.Run("returns nil when shim creation fails", func(t *testing.T) {
t.Parallel()
env := gnoDriverEnv(driverDeps{
lookPath: binaryAt("/usr/bin/gnopls"),
getenv: envFrom(map[string]string{"GNOROOT": "/gno"}),
ensureShim: failingShim,
})
assert.Nil(t, env)
})

t.Run("wires GOPACKAGESDRIVER and GNOROOT when both present", func(t *testing.T) {
t.Parallel()
env := gnoDriverEnv(driverDeps{
lookPath: binaryAt("/usr/bin/gnopls"),
getenv: envFrom(map[string]string{"GNOROOT": "/gno"}),
ensureShim: stubShim("/tmp/shim"),
})
assert.Equal(t, []string{"GOPACKAGESDRIVER=/tmp/shim", "GNOROOT=/gno"}, env)
})

t.Run("propagates GNOBUILTIN when set", func(t *testing.T) {
t.Parallel()
env := gnoDriverEnv(driverDeps{
lookPath: binaryAt("/usr/bin/gnopls"),
getenv: envFrom(map[string]string{"GNOROOT": "/gno", "GNOBUILTIN": "/gno/builtin"}),
ensureShim: stubShim("/tmp/shim"),
})
assert.Contains(t, env, "GNOBUILTIN=/gno/builtin")
})
}

func TestWriteGnoplsShim_Unix(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("unix shim format only")
}
dir := t.TempDir()
path, err := writeGnoplsShim(dir, "linux", 9001, "/usr/local/bin/gnopls")
require.NoError(t, err)
assert.Equal(t, filepath.Join(dir, "tlin-gnopls-driver-9001"), path)

body, err := os.ReadFile(path)
require.NoError(t, err)
assert.True(t, strings.HasPrefix(string(body), "#!/bin/sh\n"), "shim must start with sh shebang")
assert.Contains(t, string(body), `"/usr/local/bin/gnopls" resolve "$@"`)

info, err := os.Stat(path)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0o700), info.Mode().Perm())
}

func TestWriteGnoplsShim_Windows(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path, err := writeGnoplsShim(dir, "windows", 9002, `C:\bin\gnopls.exe`)
require.NoError(t, err)
assert.Equal(t, filepath.Join(dir, "tlin-gnopls-driver-9002.cmd"), path)

body, err := os.ReadFile(path)
require.NoError(t, err)
assert.Contains(t, string(body), "@echo off")
assert.Contains(t, string(body), `"C:\bin\gnopls.exe" resolve %*`)
}

func TestWriteGnoplsShim_FailsOnUnwritableDir(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("permission semantics differ on windows")
}
dir := filepath.Join(t.TempDir(), "missing", "deeper")
_, err := writeGnoplsShim(dir, "linux", 9003, "/bin/gnopls")
require.Error(t, err)
}
Loading