diff --git a/README.md b/README.md index e1b3362..697afda 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/internal/lints/default_golangci.go b/internal/lints/default_golangci.go index d42ad52..08d0834 100644 --- a/internal/lints/default_golangci.go +++ b/internal/lints/default_golangci.go @@ -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() diff --git a/internal/lints/gnopls_driver.go b/internal/lints/gnopls_driver.go new file mode 100644 index 0000000..d7159bf --- /dev/null +++ b/internal/lints/gnopls_driver.go @@ -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 +} diff --git a/internal/lints/gnopls_driver_test.go b/internal/lints/gnopls_driver_test.go new file mode 100644 index 0000000..157381a --- /dev/null +++ b/internal/lints/gnopls_driver_test.go @@ -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) +}