From ddab08b1f29784acf04fb12a37f0e566a4bae1fb Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Mon, 27 Apr 2026 22:25:33 +0900 Subject: [PATCH] feat(golangci): use gnopls as GOPACKAGESDRIVER when available MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect gnopls on PATH and require GNOROOT in the environment, then point golangci-lint at gnopls via a once-written shim script so go/packages can resolve gno.land/... imports. Without this wiring the standard Go loader fails for pure-gno packages and golangci-lint exits with empty stdout — the previous commit absorbs that as a silent fallback, so an environment lacking either gnopls or GNOROOT keeps the same behavior as before. GOPACKAGESDRIVER takes a single executable path, which means "gnopls resolve" needs a wrapper. Write a per-pid shim into the OS temp dir on first use and let the OS reclaim it; ship a Windows .cmd variant alongside the unix sh script. Cache the resulting subprocess env so PATH lookup, shim creation, and the os.Environ() snapshot run once per process instead of per CheckPackage call. --- README.md | 19 +++++ internal/lints/default_golangci.go | 3 + internal/lints/gnopls_driver.go | 106 +++++++++++++++++++++++ internal/lints/gnopls_driver_test.go | 122 +++++++++++++++++++++++++++ 4 files changed, 250 insertions(+) create mode 100644 internal/lints/gnopls_driver.go create mode 100644 internal/lints/gnopls_driver_test.go 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) +}