From 2234be0b9b037d8e48c78f1cdc34073b711fa594 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Wed, 18 Mar 2026 22:55:54 +0200 Subject: [PATCH] Migrate to gotreesitter parser --- .github/workflows/release.yaml | 41 +-- .goreleaser.yml | 13 - Dockerfile | 6 +- cmd/lets/main.go | 1 + docs/docs/changelog.md | 1 + go.mod | 4 +- go.sum | 35 +-- internal/cmd/root.go | 1 + internal/config/config/command.go | 16 +- internal/config/config/config.go | 10 +- internal/config/config/env_file.go | 12 +- internal/lsp/treesitter.go | 298 +++++++++------------- internal/lsp/treesitter_test.go | 237 ++++++++++++++++- tests/command_docopt_cmd_placeholder.bats | 3 +- tests/command_options.bats | 2 +- 15 files changed, 402 insertions(+), 278 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index bf1e3b96..ad494917 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -17,33 +17,18 @@ jobs: with: go-version: 1.26.x - name: Run GoReleaser (dry run) - env: - PACKAGE_NAME: github.com/lets-cli/lets - GOLANG_CROSS_VERSION: v1.26 - run: | - docker run \ - --rm \ - -e CGO_ENABLED=1 \ - -v /var/run/docker.sock:/var/run/docker.sock \ - -v `pwd`:/go/src/${PACKAGE_NAME}\ - -v `pwd`/sysroot:/sysroot \ - -w /go/src/${PACKAGE_NAME} \ - ghcr.io/goreleaser/goreleaser-cross:${GOLANG_CROSS_VERSION} \ - --clean --skip=validate --skip=publish + uses: goreleaser/goreleaser-action@v7 + with: + distribution: goreleaser + version: '~> v2' # latest + args: release --clean --skip=validate --skip=publish - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v7 + with: + distribution: goreleaser + version: '~> v2' # latest + args: release --clean env: - PACKAGE_NAME: github.com/lets-cli/lets - GOLANG_CROSS_VERSION: v1.26 - run: | - docker run \ - --rm \ - -e CGO_ENABLED=1 \ - -e GITHUB_TOKEN="${{secrets.GITHUB_TOKEN}}" \ - -e HOMEBREW_TAP_GITHUB_TOKEN="${{secrets.GH_PAT}}" \ - -e AUR_GITHUB_TOKEN="${{secrets.AUR_SSH_PRIVATE_KEY}}" \ - -v /var/run/docker.sock:/var/run/docker.sock \ - -v `pwd`:/go/src/${PACKAGE_NAME}\ - -v `pwd`/sysroot:/sysroot \ - -w /go/src/${PACKAGE_NAME} \ - ghcr.io/goreleaser/goreleaser-cross:${GOLANG_CROSS_VERSION} \ - release --clean + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.GH_PAT }} + AUR_GITHUB_TOKEN: ${{ secrets.AUR_SSH_PRIVATE_KEY }} diff --git a/.goreleaser.yml b/.goreleaser.yml index 4b6629ea..bc5af607 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -15,11 +15,6 @@ builds: - darwin goarch: - amd64 - env: - - PKG_CONFIG_SYSROOT_DIR=/sysroot/macos/amd64 - - PKG_CONFIG_PATH=/sysroot/macos/amd64/usr/local/lib/pkgconfig - - CC=o64-clang - - CXX=o64-clang++ flags: - -mod=readonly ldflags: @@ -30,11 +25,6 @@ builds: - darwin goarch: - arm64 - env: - - PKG_CONFIG_SYSROOT_DIR=/sysroot/macos/arm64 - - PKG_CONFIG_PATH=/sysroot/macos/arm64/usr/local/lib/pkgconfig - - CC=oa64-clang - - CXX=oa64-clang++ flags: - -mod=readonly ldflags: @@ -45,9 +35,6 @@ builds: - linux goarch: - amd64 - env: - - CC=x86_64-linux-gnu-gcc - - CXX=x86_64-linux-gnu-g++ flags: - -mod=readonly ldflags: diff --git a/Dockerfile b/Dockerfile index 7ba47de3..ecf0426c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,12 @@ FROM golang:1.26-bookworm AS builder ENV GOPROXY=https://proxy.golang.org -ENV CGO_ENABLED=1 -# disable all compiler errors -ENV CGO_CFLAGS=-w +ENV CGO_ENABLED=0 WORKDIR /app RUN apt-get update && apt-get install -y \ - git gcc \ + git \ zsh # for zsh completion tests RUN cd /tmp && \ diff --git a/cmd/lets/main.go b/cmd/lets/main.go index 5632497c..e9939102 100644 --- a/cmd/lets/main.go +++ b/cmd/lets/main.go @@ -126,6 +126,7 @@ func main() { if errors.As(err, &depErr) { executor.PrintDependencyTree(depErr, os.Stderr) } + log.Errorf("lets: %s", err.Error()) os.Exit(getExitCode(err, 1)) } diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md index b00a95fd..41f61b2f 100644 --- a/docs/docs/changelog.md +++ b/docs/docs/changelog.md @@ -12,6 +12,7 @@ title: Changelog * `[Removed]` Drop deprecated `eval_env` directive. Use `env` with `sh` execution mode instead. * `[Added]` When a command or its `depends` chain fails, print an indented tree to stderr showing the full chain with the failing command highlighted * `[Added]` Support `env_file` in global config and commands. File names are expanded after `env` is resolved, and values loaded from env files override values from `env`. +* `[Changed]` Migrate the LSP YAML parser from the CGO-based tree-sitter bindings to pure-Go [`gotreesitter`](https://github.com/odvcencio/gotreesitter), removing the C toolchain requirement from normal builds and release packaging. ## [0.0.59](https://github.com/lets-cli/lets/releases/tag/v0.0.59) diff --git a/go.mod b/go.mod index d9d2625e..8ce43b3d 100644 --- a/go.mod +++ b/go.mod @@ -9,13 +9,12 @@ require ( github.com/coreos/go-semver v0.3.1 github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 github.com/fatih/color v1.16.0 + github.com/odvcencio/gotreesitter v0.9.2 github.com/pkg/errors v0.9.1 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.8.0 github.com/tliron/commonlog v0.2.8 github.com/tliron/glsp v0.2.2 - github.com/tree-sitter-grammars/tree-sitter-yaml v0.7.0 - github.com/tree-sitter/go-tree-sitter v0.24.0 golang.org/x/sync v0.3.0 ) @@ -26,7 +25,6 @@ require ( github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-pointer v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect github.com/muesli/termenv v0.15.2 // indirect github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect diff --git a/go.sum b/go.sum index 90627703..60184dbe 100644 --- a/go.sum +++ b/go.sum @@ -72,8 +72,6 @@ github.com/mattn/go-isatty v0.0.0-20160806122752-66b8e73f3f5c/go.mod h1:M+lRXTBq github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o0= -github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc= github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= @@ -81,6 +79,8 @@ github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1n github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= +github.com/odvcencio/gotreesitter v0.9.2 h1:ZROpRS+bTcC1mwofBp53l66Jv00FH0ccViSwGVmaBBM= +github.com/odvcencio/gotreesitter v0.9.2/go.mod h1:Sx+iYJBfw5xSWkSttLSuFvguJctlH+ma1BTxZ0MPCqo= github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 h1:q2e307iGHPdTGp0hoxKjt1H5pDo6utceo3dQVK3I5XQ= github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5/go.mod h1:jvVRKCrJTQWu0XVbaOlby/2lO20uSCHEMzzplHXte1o= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -101,43 +101,14 @@ github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyh github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tliron/commonlog v0.2.8 h1:vpKrEsZX4nlneC9673pXpeKqv3cFLxwpzNEZF1qiaQQ= github.com/tliron/commonlog v0.2.8/go.mod h1:HgQZrJEuiKLLRvUixtPWGcmTmWWtKkCtywF6x9X5Spw= github.com/tliron/glsp v0.2.2 h1:IKPfwpE8Lu8yB6Dayta+IyRMAbTVunudeauEgjXBt+c= github.com/tliron/glsp v0.2.2/go.mod h1:GMVWDNeODxHzmDPvYbYTCs7yHVaEATfYtXiYJ9w1nBg= github.com/tliron/kutil v0.3.11 h1:kongR0dhrrn9FR/3QRFoUfQe27t78/xQvrU9aXIy5bk= github.com/tliron/kutil v0.3.11/go.mod h1:4IqOAAdpJuDxYbJxMv4nL8LSH0mPofSrdwIv8u99PDc= -github.com/tree-sitter-grammars/tree-sitter-yaml v0.7.0 h1:bfSjXf9nNPbZH09as6k9+/fbMPyQEHD9IJXzGMdQc0o= -github.com/tree-sitter-grammars/tree-sitter-yaml v0.7.0/go.mod h1:ioP5ekY1SBtpcsagA3mQ4GNUBunkPhtVgqplC5Nffc8= -github.com/tree-sitter/go-tree-sitter v0.24.0 h1:kRZb6aBNfcI/u0Qh8XEt3zjNVnmxTisDBN+kXK0xRYQ= -github.com/tree-sitter/go-tree-sitter v0.24.0/go.mod h1:x681iFVoLMEwOSIHA1chaLkXlroXEN7WY+VHGFaoDbk= -github.com/tree-sitter/tree-sitter-c v0.21.5-0.20240818205408-927da1f210eb h1:A8425heRM8mylnv4H58FPUiH+aYivyitre0PzxrfmWs= -github.com/tree-sitter/tree-sitter-c v0.21.5-0.20240818205408-927da1f210eb/go.mod h1:dOF6gtQiF9UwNh995T5OphYmtIypkjsp3ap7r9AN/iA= -github.com/tree-sitter/tree-sitter-cpp v0.22.4-0.20240818224355-b1a4e2b25148 h1:AfFPZwtwGN01BW1jDdqBVqscTwetvMpydqYZz57RSlc= -github.com/tree-sitter/tree-sitter-cpp v0.22.4-0.20240818224355-b1a4e2b25148/go.mod h1:Bh6U3viD57rFXRYIQ+kmiYtr+1Bx0AceypDLJJSyi9s= -github.com/tree-sitter/tree-sitter-embedded-template v0.21.1-0.20240819044651-ffbf64942c33 h1:TwqSV3qLp3tKSqirGLRHnjFk9Tc2oy57LIl+FQ4GjI4= -github.com/tree-sitter/tree-sitter-embedded-template v0.21.1-0.20240819044651-ffbf64942c33/go.mod h1:CvCKCt3v04Ufos1zZnNCelBDeCGRpPucaN8QczoUsN4= -github.com/tree-sitter/tree-sitter-go v0.21.3-0.20240818010209-8c0f0e7a6012 h1:Xvxck3tE5FW7F7bTS97iNM2ADMyCMJztVqn5HYKdJGo= -github.com/tree-sitter/tree-sitter-go v0.21.3-0.20240818010209-8c0f0e7a6012/go.mod h1:T40D0O1cPvUU/+AmiXVXy1cncYQT6wem4Z0g4SfAYvY= -github.com/tree-sitter/tree-sitter-html v0.20.5-0.20240818004741-d11201a263d0 h1:c46K6uh5Dz00zJeU9BfjXdb8I+E4RkUdfnWJpQADXFo= -github.com/tree-sitter/tree-sitter-html v0.20.5-0.20240818004741-d11201a263d0/go.mod h1:hcNt/kOJHcIcuMvouE7LJcYdeFUFbVpBJ6d4wmOA+tU= -github.com/tree-sitter/tree-sitter-java v0.21.1-0.20240824015150-576d8097e495 h1:jrt4qbJVEFs4H93/ITxygHc6u0TGqAkkate7TQ4wFSA= -github.com/tree-sitter/tree-sitter-java v0.21.1-0.20240824015150-576d8097e495/go.mod h1:oyaR7fLnRV0hT9z6qwE9GkaeTom/hTDwK3H2idcOJFc= -github.com/tree-sitter/tree-sitter-javascript v0.21.5-0.20240818005344-15887341e5b5 h1:om4X9AVg3asL8gxNJDcz4e/Wp+VpQj1PY3uJXKr6EOg= -github.com/tree-sitter/tree-sitter-javascript v0.21.5-0.20240818005344-15887341e5b5/go.mod h1:nNqgPoV/h9uYWk6kYEFdEAhNVOacpfpRW5SFmdaP4tU= -github.com/tree-sitter/tree-sitter-json v0.21.1-0.20240818005659-bdd69eb8c8a5 h1:pfV3G3k7NCKqKk8THBmyuh2zA33lgYHS3GVrzRR8ry4= -github.com/tree-sitter/tree-sitter-json v0.21.1-0.20240818005659-bdd69eb8c8a5/go.mod h1:GbMKRjLfk0H+PI7nLi1Sx5lHf5wCpLz9al8tQYSxpEk= -github.com/tree-sitter/tree-sitter-php v0.22.9-0.20240819002312-a552625b56c1 h1:ZXZMDwE+IhUtGug4Brv6NjJWUU3rfkZBKpemf6RY8/g= -github.com/tree-sitter/tree-sitter-php v0.22.9-0.20240819002312-a552625b56c1/go.mod h1:UKCLuYnJ312Mei+3cyTmGOHzn0YAnaPRECgJmHtzrqs= -github.com/tree-sitter/tree-sitter-python v0.21.1-0.20240818005537-55a9b8a4fbfb h1:EXEM82lFM7JjJb6qiKZXkpIDaCcbV2obNn82ghwj9lw= -github.com/tree-sitter/tree-sitter-python v0.21.1-0.20240818005537-55a9b8a4fbfb/go.mod h1:lXCF1nGG5Dr4J3BTS0ObN4xJCCICiSu/b+Xe/VqMV7g= -github.com/tree-sitter/tree-sitter-ruby v0.21.1-0.20240818211811-7dbc1e2d0e2d h1:fcYCvoXdcP1uRQYXqJHRy6Hec+uKScQdKVtMwK9JeCI= -github.com/tree-sitter/tree-sitter-ruby v0.21.1-0.20240818211811-7dbc1e2d0e2d/go.mod h1:T1nShQ4v5AJtozZ8YyAS4uzUtDAJj/iv4YfwXSbUHzg= -github.com/tree-sitter/tree-sitter-rust v0.21.3-0.20240818005432-2b43eafe6447 h1:o9alBu1J/WjrcTKEthYtXmdkDc5OVXD+PqlvnEZ0Lzc= -github.com/tree-sitter/tree-sitter-rust v0.21.3-0.20240818005432-2b43eafe6447/go.mod h1:1Oh95COkkTn6Ezp0vcMbvfhRP5gLeqqljR0BYnBzWvc= golang.org/x/crypto v0.0.0-20180214000028-650f4a345ab4/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= diff --git a/internal/cmd/root.go b/internal/cmd/root.go index d4170a2d..d2ca8823 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -228,6 +228,7 @@ func PrintRootHelpMessage(cmd *cobra.Command) error { // General builder.WriteString("Usage:\n") + if cmd.Runnable() { fmt.Fprintf(&builder, " %s\n", cmd.UseLine()) } diff --git a/internal/config/config/command.go b/internal/config/config/command.go index fcb9b855..da15fb59 100644 --- a/internal/config/config/command.go +++ b/internal/config/config/command.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "maps" "path/filepath" "strings" @@ -92,6 +93,7 @@ func (c *Command) UnmarshalYAML(unmarshal func(any) error) error { if c.Env == nil { c.Env = &Envs{} } + c.EnvFiles = cmd.EnvFiles if c.EnvFiles == nil { c.EnvFiles = &EnvFiles{} @@ -145,9 +147,8 @@ func (c *Command) GetEnv(cfg Config, builtinEnv map[string]string) (map[string]s if baseEnv == nil { baseEnv = make(map[string]string) } - for key, value := range cfg.GetEnv() { - baseEnv[key] = value - } + + maps.Copy(baseEnv, cfg.GetEnv()) envs := c.Env.Clone() if err := envs.Execute(cfg, baseEnv); err != nil { @@ -155,20 +156,17 @@ func (c *Command) GetEnv(cfg Config, builtinEnv map[string]string) (map[string]s } filenameEnv := cloneMap(baseEnv) - for key, value := range envs.Dump() { - filenameEnv[key] = value - } + maps.Copy(filenameEnv, envs.Dump()) envFiles := c.EnvFiles.Clone() + envFileEnv, err := envFiles.Load(cfg, filenameEnv) if err != nil { return nil, fmt.Errorf("lets: failed to resolve env_file for command '%s': %w", c.Name, err) } resolvedEnv := envs.Dump() - for key, value := range envFileEnv { - resolvedEnv[key] = value - } + maps.Copy(resolvedEnv, envFileEnv) return resolvedEnv, nil } diff --git a/internal/config/config/config.go b/internal/config/config/config.go index 165609f9..3415e2bf 100644 --- a/internal/config/config/config.go +++ b/internal/config/config/config.go @@ -4,6 +4,7 @@ import ( "bytes" "errors" "fmt" + "maps" "os" "path/filepath" "strings" @@ -100,6 +101,7 @@ func (c *Config) UnmarshalYAML(unmarshal func(any) error) error { if c.Env == nil { c.Env = &Envs{} } + c.EnvFiles = config.EnvFiles if c.EnvFiles == nil { c.EnvFiles = &EnvFiles{} @@ -314,9 +316,7 @@ func (c *Config) SetupEnv() error { } filenameEnv := c.BuiltinEnv(c.Shell) - for key, value := range c.Env.Dump() { - filenameEnv[key] = value - } + maps.Copy(filenameEnv, c.Env.Dump()) envFileEnv, err := c.EnvFiles.Load(*c, filenameEnv) if err != nil { @@ -324,9 +324,7 @@ func (c *Config) SetupEnv() error { } c.cachedEnv = c.Env.Dump() - for key, value := range envFileEnv { - c.cachedEnv[key] = value - } + maps.Copy(c.cachedEnv, envFileEnv) // expand env for args for _, cmd := range c.Commands { diff --git a/internal/config/config/env_file.go b/internal/config/config/env_file.go index 8f690712..32372745 100644 --- a/internal/config/config/env_file.go +++ b/internal/config/config/env_file.go @@ -3,6 +3,7 @@ package config import ( "errors" "fmt" + "maps" "os" "path/filepath" "strings" @@ -23,11 +24,12 @@ type EnvFiles struct { ready bool } -func (e *EnvFile) UnmarshalYAML(unmarshal func(interface{}) error) error { +func (e *EnvFile) UnmarshalYAML(unmarshal func(any) error) error { var filename string // try parse as scalar if err := unmarshal(&filename); err == nil { e.Name = normalizeEnvFilename(filename) + e.Required = !isOptionalEnvFilename(filename) if e.Name == "" { return errors.New("env_file name can not be empty") @@ -54,6 +56,7 @@ func (e *EnvFile) UnmarshalYAML(unmarshal func(interface{}) error) error { } e.Name = raw.Name + e.Required = true if raw.Required != nil { e.Required = *raw.Required @@ -71,6 +74,7 @@ func (e *EnvFiles) UnmarshalYAML(node *yaml.Node) error { } e.Items = []EnvFile{item} + return nil case yaml.SequenceNode: items := make([]EnvFile, 0, len(node.Content)) @@ -79,10 +83,12 @@ func (e *EnvFiles) UnmarshalYAML(node *yaml.Node) error { if err := itemNode.Decode(&item); err != nil { return err } + items = append(items, item) } e.Items = items + return nil default: return errors.New("env_file must be a string, map, or sequence") @@ -144,9 +150,7 @@ func (e *EnvFiles) Load(cfg Config, envMap map[string]string) (map[string]string return nil, fmt.Errorf("failed to parse env_file %q: %w", filename, err) } - for key, value := range values { - loaded[key] = value - } + maps.Copy(loaded, values) } e.loaded = loaded diff --git a/internal/lsp/treesitter.go b/internal/lsp/treesitter.go index 45d30b37..81630b42 100644 --- a/internal/lsp/treesitter.go +++ b/internal/lsp/treesitter.go @@ -3,10 +3,10 @@ package lsp import ( "strings" + ts "github.com/odvcencio/gotreesitter" + "github.com/odvcencio/gotreesitter/grammars" "github.com/tliron/commonlog" lsp "github.com/tliron/glsp/protocol_3_16" - tree_sitter_yaml "github.com/tree-sitter-grammars/tree-sitter-yaml/bindings/go" - ts "github.com/tree-sitter/go-tree-sitter" ) type PositionType int @@ -17,20 +17,22 @@ const ( PositionTypeNone ) +var yamlLanguage = grammars.YamlLanguage() + func isCursorWithinNode(node *ts.Node, pos lsp.Position) bool { - return isCursorWithinNodePoints(node.StartPosition(), node.EndPosition(), pos) + return isCursorWithinNodePoints(node.StartPoint(), node.EndPoint(), pos) } func isCursorWithinNodePoints(startPoint, endPoint ts.Point, pos lsp.Position) bool { - if uint(pos.Line) < startPoint.Row || uint(pos.Line) > endPoint.Row { + if pos.Line < startPoint.Row || pos.Line > endPoint.Row { return false } - if uint(pos.Line) == startPoint.Row && uint(pos.Character) < startPoint.Column { + if pos.Line == startPoint.Row && pos.Character < startPoint.Column { return false } - if uint(pos.Line) == endPoint.Row && uint(pos.Character) > endPoint.Column { + if pos.Line == endPoint.Row && pos.Character > endPoint.Column { return false } @@ -38,10 +40,21 @@ func isCursorWithinNodePoints(startPoint, endPoint ts.Point, pos lsp.Position) b } func isCursorAtLine(node *ts.Node, pos lsp.Position) bool { - startPoint := node.StartPosition() - endPoint := node.EndPosition() + startPoint := node.StartPoint() + endPoint := node.EndPoint() - return uint(pos.Line) == startPoint.Row && uint(pos.Line) == endPoint.Row + return pos.Line == startPoint.Row && pos.Line == endPoint.Row +} + +func parseYAMLDocument(document *string) (*ts.Tree, []byte, error) { + docBytes := []byte(*document) + + tree, err := ts.NewParser(yamlLanguage).Parse(docBytes) + if err != nil { + return nil, nil, err + } + + return tree, docBytes, nil } func getLine(document *string, line uint32) string { @@ -108,20 +121,13 @@ func (p *parser) getPositionType(document *string, position lsp.Position) Positi } func (p *parser) inMixinsPosition(document *string, position lsp.Position) bool { - parser := ts.NewParser() - defer parser.Close() - - lang := ts.NewLanguage(tree_sitter_yaml.Language()) - if err := parser.SetLanguage(lang); err != nil { + tree, docBytes, err := parseYAMLDocument(document) + if err != nil { return false } + defer tree.Release() - docBytes := []byte(*document) - - tree := parser.Parse(docBytes, nil) - defer tree.Close() - - query, err := ts.NewQuery(lang, ` + query, err := ts.NewQuery(` (block_mapping_pair key: (flow_node) @key value: (block_node @@ -130,30 +136,28 @@ func (p *parser) inMixinsPosition(document *string, position lsp.Position) bool (flow_node) @value))) (#eq? @key "mixins") ) - `) + `, yamlLanguage) if err != nil { return false } - defer query.Close() - root := tree.RootNode() + if root == nil { + return false + } - cursor := ts.NewQueryCursor() - defer cursor.Close() - - matches := cursor.Matches(query, root, docBytes) + matches := query.Exec(root, yamlLanguage, docBytes) for { - match := matches.Next() - if match == nil { + match, ok := matches.NextMatch() + if !ok { break } for _, capture := range match.Captures { if parent := capture.Node.Parent(); parent != nil { - nodeText := capture.Node.Utf8Text(docBytes) - if parent.Kind() == "block_mapping_pair" && + nodeText := capture.Node.Text(docBytes) + if parent.Type(yamlLanguage) == "block_mapping_pair" && nodeText == "mixins" && isCursorWithinNode(parent, position) { return true @@ -166,20 +170,13 @@ func (p *parser) inMixinsPosition(document *string, position lsp.Position) bool } func (p *parser) inDependsPosition(document *string, position lsp.Position) bool { - parser := ts.NewParser() - defer parser.Close() - - lang := ts.NewLanguage(tree_sitter_yaml.Language()) - if err := parser.SetLanguage(lang); err != nil { + tree, docBytes, err := parseYAMLDocument(document) + if err != nil { return false } + defer tree.Release() - docBytes := []byte(*document) - - tree := parser.Parse(docBytes, nil) - defer tree.Close() - - query, err := ts.NewQuery(lang, ` + query, err := ts.NewQuery(` (block_mapping_pair key: (flow_node) @keydepends value: [ @@ -189,43 +186,40 @@ func (p *parser) inDependsPosition(document *string, position lsp.Position) bool ] (#eq? @keydepends "depends") ) - `) + `, yamlLanguage) if err != nil { return false } - defer query.Close() root := tree.RootNode() + if root == nil { + return false + } - cursor := ts.NewQueryCursor() - defer cursor.Close() - - matches := cursor.Matches(query, root, docBytes) - - dependsIndex, _ := query.CaptureIndexForName("depends") + matches := query.Exec(root, yamlLanguage, docBytes) for { - match := matches.Next() - if match == nil { + match, ok := matches.NextMatch() + if !ok { break } for _, capture := range match.Captures { - nodeKind := capture.Node.Kind() - - if capture.Index != uint32(dependsIndex) { + if capture.Name != "depends" { continue } + nodeKind := capture.Node.Type(yamlLanguage) + // if is a sequence switch nodeKind { case "block_sequence_item", "block_sequence": - if isCursorWithinNode(&capture.Node, position) || isCursorAtLine(&capture.Node, position) { + if isCursorWithinNode(capture.Node, position) || isCursorAtLine(capture.Node, position) { return true } // if is an array case "flow_sequence", "flow_node": - if isCursorWithinNode(&capture.Node, position) { + if isCursorWithinNode(capture.Node, position) { return true } } @@ -236,20 +230,13 @@ func (p *parser) inDependsPosition(document *string, position lsp.Position) bool } func (p *parser) extractFilenameFromMixins(document *string, position lsp.Position) string { - parser := ts.NewParser() - defer parser.Close() - - lang := ts.NewLanguage(tree_sitter_yaml.Language()) - if err := parser.SetLanguage(lang); err != nil { + tree, docBytes, err := parseYAMLDocument(document) + if err != nil { return "" } + defer tree.Release() - docBytes := []byte(*document) - - tree := parser.Parse(docBytes, nil) - defer tree.Close() - - query, err := ts.NewQuery(lang, ` + query, err := ts.NewQuery(` (block_mapping_pair key: (flow_node) @key value: (block_node @@ -258,29 +245,28 @@ func (p *parser) extractFilenameFromMixins(document *string, position lsp.Positi (flow_node) @value))) (#eq? @key "mixins") ) - `) + `, yamlLanguage) if err != nil { return "" } - defer query.Close() root := tree.RootNode() + if root == nil { + return "" + } - cursor := ts.NewQueryCursor() - defer cursor.Close() - - matches := cursor.Matches(query, root, docBytes) + matches := query.Exec(root, yamlLanguage, docBytes) for { - match := matches.Next() - if match == nil { + match, ok := matches.NextMatch() + if !ok { break } for _, capture := range match.Captures { if parent := capture.Node.Parent(); parent != nil { - if parent.Kind() == "block_sequence_item" && isCursorAtLine(&capture.Node, position) { - return capture.Node.Utf8Text(docBytes) + if parent.Type(yamlLanguage) == "block_sequence_item" && isCursorAtLine(capture.Node, position) { + return capture.Node.Text(docBytes) } } } @@ -296,20 +282,13 @@ type Command struct { } func (p *parser) getCommands(document *string) []Command { - parser := ts.NewParser() - defer parser.Close() - - lang := ts.NewLanguage(tree_sitter_yaml.Language()) - if err := parser.SetLanguage(lang); err != nil { + tree, docBytes, err := parseYAMLDocument(document) + if err != nil { return nil } + defer tree.Release() - docBytes := []byte(*document) - - tree := parser.Parse(docBytes, nil) - defer tree.Close() - - query, err := ts.NewQuery(lang, ` + query, err := ts.NewQuery(` (block_mapping_pair key: (flow_node(plain_scalar(string_scalar)) @parent) value: (block_node @@ -321,36 +300,33 @@ func (p *parser) getCommands(document *string) []Command { value: (block_node) @cmd) @values)) (#eq? @parent "commands") ) - `) + `, yamlLanguage) if err != nil { return nil } - defer query.Close() root := tree.RootNode() + if root == nil { + return nil + } - cursor := ts.NewQueryCursor() - defer cursor.Close() - - matches := cursor.Matches(query, root, docBytes) + matches := query.Exec(root, yamlLanguage, docBytes) var commands []Command - cmdKeyIndex, _ := query.CaptureIndexForName("cmd_key") - for { - match := matches.Next() - if match == nil { + match, ok := matches.NextMatch() + if !ok { break } for _, capture := range match.Captures { - if capture.Index == uint32(cmdKeyIndex) { + if capture.Name == "cmd_key" { commands = append(commands, Command{ - name: capture.Node.Utf8Text(docBytes), + name: capture.Node.Text(docBytes), position: lsp.Position{ - Line: uint32(capture.Node.StartPosition().Row), - Character: uint32(capture.Node.StartPosition().Column), + Line: capture.Node.StartPoint().Row, + Character: capture.Node.StartPoint().Column, }, }) } @@ -361,20 +337,13 @@ func (p *parser) getCommands(document *string) []Command { } func (p *parser) getCurrentCommand(document *string, position lsp.Position) *Command { - parser := ts.NewParser() - defer parser.Close() - - lang := ts.NewLanguage(tree_sitter_yaml.Language()) - if err := parser.SetLanguage(lang); err != nil { + tree, docBytes, err := parseYAMLDocument(document) + if err != nil { return nil } + defer tree.Release() - docBytes := []byte(*document) - - tree := parser.Parse(docBytes, nil) - defer tree.Close() - - query, err := ts.NewQuery(lang, ` + query, err := ts.NewQuery(` (block_mapping_pair key: (flow_node(plain_scalar(string_scalar)) @commands) value: (block_node @@ -382,39 +351,36 @@ func (p *parser) getCurrentCommand(document *string, position lsp.Position) *Com (block_mapping_pair) @cmd)) (#eq? @commands "commands") ) - `) + `, yamlLanguage) if err != nil { return nil } - defer query.Close() root := tree.RootNode() + if root == nil { + return nil + } - cursor := ts.NewQueryCursor() - defer cursor.Close() - - matches := cursor.Matches(query, root, docBytes) - - cmdIndex, _ := query.CaptureIndexForName("cmd") + matches := query.Exec(root, yamlLanguage, docBytes) for { - match := matches.Next() - if match == nil { + match, ok := matches.NextMatch() + if !ok { break } for _, capture := range match.Captures { - if capture.Index != uint32(cmdIndex) { + if capture.Name != "cmd" { continue } - if !isCursorWithinNode(&capture.Node, position) { + if !isCursorWithinNode(capture.Node, position) { continue } - if key := capture.Node.ChildByFieldName("key"); key != nil { + if key := capture.Node.ChildByFieldName("key", yamlLanguage); key != nil { return &Command{ - name: key.Utf8Text(docBytes), + name: key.Text(docBytes), } } } @@ -424,20 +390,13 @@ func (p *parser) getCurrentCommand(document *string, position lsp.Position) *Com } func (p *parser) findCommand(document *string, commandName string) *Command { - parser := ts.NewParser() - defer parser.Close() - - lang := ts.NewLanguage(tree_sitter_yaml.Language()) - if err := parser.SetLanguage(lang); err != nil { + tree, docBytes, err := parseYAMLDocument(document) + if err != nil { return nil } + defer tree.Release() - docBytes := []byte(*document) - - tree := parser.Parse(docBytes, nil) - defer tree.Close() - - query, err := ts.NewQuery(lang, ` + query, err := ts.NewQuery(` (block_mapping_pair key: (flow_node(plain_scalar(string_scalar)) @commands) value: (block_node @@ -449,38 +408,35 @@ func (p *parser) findCommand(document *string, commandName string) *Command { value: (block_node) @cmd_value)) @values) (#eq? @commands "commands") ) - `) + `, yamlLanguage) if err != nil { return nil } - defer query.Close() root := tree.RootNode() + if root == nil { + return nil + } - cursor := ts.NewQueryCursor() - defer cursor.Close() - - matches := cursor.Matches(query, root, docBytes) - - cmdKeyIndex, _ := query.CaptureIndexForName("cmd_key") + matches := query.Exec(root, yamlLanguage, docBytes) for { - match := matches.Next() - if match == nil { + match, ok := matches.NextMatch() + if !ok { break } for _, capture := range match.Captures { - if capture.Index != uint32(cmdKeyIndex) { + if capture.Name != "cmd_key" { continue } - if capture.Node.Utf8Text(docBytes) == commandName { + if capture.Node.Text(docBytes) == commandName { return &Command{ name: commandName, position: lsp.Position{ - Line: uint32(capture.Node.StartPosition().Row), - Character: uint32(capture.Node.StartPosition().Column), + Line: capture.Node.StartPoint().Row, + Character: capture.Node.StartPoint().Column, }, } } @@ -491,20 +447,13 @@ func (p *parser) findCommand(document *string, commandName string) *Command { } func (p *parser) extractDependsValues(document *string) []string { - parser := ts.NewParser() - defer parser.Close() - - lang := ts.NewLanguage(tree_sitter_yaml.Language()) - if err := parser.SetLanguage(lang); err != nil { + tree, docBytes, err := parseYAMLDocument(document) + if err != nil { return nil } + defer tree.Release() - docBytes := []byte(*document) - - tree := parser.Parse(docBytes, nil) - defer tree.Close() - - query, err := ts.NewQuery(lang, ` + query, err := ts.NewQuery(` (block_mapping_pair key: (flow_node) @key value: [ @@ -522,32 +471,29 @@ func (p *parser) extractDependsValues(document *string) []string { ] (#eq? @key "depends") ) - `) + `, yamlLanguage) if err != nil { return nil } - defer query.Close() root := tree.RootNode() + if root == nil { + return nil + } - cursor := ts.NewQueryCursor() - defer cursor.Close() - - matches := cursor.Matches(query, root, docBytes) + matches := query.Exec(root, yamlLanguage, docBytes) var values []string - valueIndex, _ := query.CaptureIndexForName("value") - for { - match := matches.Next() - if match == nil { + match, ok := matches.NextMatch() + if !ok { break } for _, capture := range match.Captures { - if capture.Index == uint32(valueIndex) { - values = append(values, capture.Node.Utf8Text(docBytes)) + if capture.Name == "value" { + values = append(values, capture.Node.Text(docBytes)) } } } diff --git a/internal/lsp/treesitter_test.go b/internal/lsp/treesitter_test.go index a762e301..516abd09 100644 --- a/internal/lsp/treesitter_test.go +++ b/internal/lsp/treesitter_test.go @@ -4,13 +4,20 @@ import ( "reflect" "testing" + ts "github.com/odvcencio/gotreesitter" "github.com/tliron/commonlog" lsp "github.com/tliron/glsp/protocol_3_16" - ts "github.com/tree-sitter/go-tree-sitter" ) var logger = commonlog.GetLogger("test") +func pos(line, character uint32) lsp.Position { + return lsp.Position{ + Line: line, + Character: character, + } +} + func TestIsCursorWithinNode(t *testing.T) { tests := []struct { startPoint ts.Point @@ -329,6 +336,234 @@ commands: } } +func TestMixinsHelpersWithMultipleItems(t *testing.T) { + blockDoc := `shell: bash +mixins: + - lets.base.yaml + - lets.extra.yaml +commands: + build: + cmd: echo build` + + flowDoc := `shell: bash +mixins: [lets.base.yaml, lets.extra.yaml] +commands: + build: + cmd: echo build` + + tests := []struct { + name string + doc string + position lsp.Position + wantInMixins bool + wantFilename string + }{ + { + name: "block key line is inside mixins", + doc: blockDoc, + position: pos(1, 1), + wantInMixins: true, + }, + { + name: "block first item resolves filename", + doc: blockDoc, + position: pos(2, 4), + wantInMixins: true, + wantFilename: "lets.base.yaml", + }, + { + name: "block second item resolves filename", + doc: blockDoc, + position: pos(3, 10), + wantInMixins: true, + wantFilename: "lets.extra.yaml", + }, + { + name: "outside mixins is false", + doc: blockDoc, + position: pos(4, 0), + wantInMixins: false, + }, + { + name: "flow mixins are not matched by query", + doc: flowDoc, + position: pos(1, 12), + wantInMixins: false, + }, + } + + p := newParser(logger) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotInMixins := p.inMixinsPosition(&tt.doc, tt.position) + if gotInMixins != tt.wantInMixins { + t.Fatalf("inMixinsPosition() = %v, want %v", gotInMixins, tt.wantInMixins) + } + + gotFilename := p.extractFilenameFromMixins(&tt.doc, tt.position) + if gotFilename != tt.wantFilename { + t.Fatalf("extractFilenameFromMixins() = %q, want %q", gotFilename, tt.wantFilename) + } + }) + } +} + +func TestDependsHelpersWithBlockAndFlowSequences(t *testing.T) { + doc := `shell: bash +commands: + build: + depends: + - clean + - lint + env: + GOFLAGS: -mod=mod + cmd: echo build + test: + depends: [build, lint] + options: | + Usage: lets test [--watch] + cmd: echo test + package: + cmd: echo package` + + tests := []struct { + name string + pos lsp.Position + want bool + }{ + {name: "block key line is not inside depends values", pos: pos(3, 4), want: false}, + {name: "block first item", pos: pos(4, 8), want: true}, + {name: "block second item", pos: pos(5, 8), want: true}, + {name: "nested env is outside depends", pos: pos(6, 4), want: false}, + {name: "flow first item", pos: pos(10, 14), want: true}, + {name: "flow second item", pos: pos(10, 21), want: true}, + {name: "nested options are outside depends", pos: pos(12, 12), want: false}, + {name: "command without depends", pos: pos(15, 10), want: false}, + } + + p := newParser(logger) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := p.inDependsPosition(&doc, tt.pos) + if got != tt.want { + t.Fatalf("inDependsPosition() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCommandHelpersWithDifferentCommandShapes(t *testing.T) { + doc := `shell: bash +commands: + bootstrap: + cmd: echo bootstrap + build: + depends: + - bootstrap + - lint + env: + GOFLAGS: -mod=mod + cmd: | + echo build + echo done + test: + depends: [build, lint] + options: | + Usage: lets test [--watch] + cmd: echo test + lint: + cmd: echo lint` + + p := newParser(logger) + + expectedCommands := []Command{ + {name: "bootstrap", position: pos(2, 2)}, + {name: "build", position: pos(4, 2)}, + {name: "test", position: pos(13, 2)}, + {name: "lint", position: pos(18, 2)}, + } + + commands := p.getCommands(&doc) + if !reflect.DeepEqual(commands, expectedCommands) { + t.Fatalf("getCommands() = %#v, want %#v", commands, expectedCommands) + } + + findTests := []struct { + name string + command string + want *Command + }{ + {name: "find bootstrap", command: "bootstrap", want: &Command{name: "bootstrap", position: pos(2, 2)}}, + {name: "find build", command: "build", want: &Command{name: "build", position: pos(4, 2)}}, + {name: "find test", command: "test", want: &Command{name: "test", position: pos(13, 2)}}, + {name: "find lint", command: "lint", want: &Command{name: "lint", position: pos(18, 2)}}, + {name: "missing command", command: "missing", want: nil}, + } + + for _, tt := range findTests { + t.Run(tt.name, func(t *testing.T) { + got := p.findCommand(&doc, tt.command) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("findCommand() = %#v, want %#v", got, tt.want) + } + }) + } + + currentTests := []struct { + name string + position lsp.Position + want *Command + }{ + {name: "inside bootstrap command body", position: pos(3, 12), want: &Command{name: "bootstrap"}}, + {name: "inside build env block", position: pos(9, 10), want: &Command{name: "build"}}, + {name: "inside build multiline cmd", position: pos(11, 8), want: &Command{name: "build"}}, + {name: "inside test flow depends", position: pos(14, 18), want: &Command{name: "test"}}, + {name: "inside test options block", position: pos(16, 12), want: &Command{name: "test"}}, + {name: "inside lint command body", position: pos(19, 10), want: &Command{name: "lint"}}, + {name: "outside commands tree", position: pos(0, 0), want: nil}, + } + + for _, tt := range currentTests { + t.Run(tt.name, func(t *testing.T) { + got := p.getCurrentCommand(&doc, tt.position) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("getCurrentCommand() = %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestExtractDependsValuesFromMixedCommands(t *testing.T) { + doc := `shell: bash +commands: + build: + depends: + - bootstrap + - lint + cmd: echo build + test: + depends: [build, lint] + cmd: echo test + release: + env: + TARGET: prod + depends: + - test + cmd: echo release + lint: + cmd: echo lint` + + p := newParser(logger) + got := p.extractDependsValues(&doc) + want := []string{"bootstrap", "lint", "build", "lint", "test"} + + if !reflect.DeepEqual(got, want) { + t.Fatalf("extractDependsValues() = %#v, want %#v", got, want) + } +} + func TestWordUnderCursor(t *testing.T) { tests := []struct { line string diff --git a/tests/command_docopt_cmd_placeholder.bats b/tests/command_docopt_cmd_placeholder.bats index ed5baf54..5f512c6b 100644 --- a/tests/command_docopt_cmd_placeholder.bats +++ b/tests/command_docopt_cmd_placeholder.bats @@ -19,5 +19,6 @@ setup() { run lets cmd-2 posarg --config=some_path assert_failure - assert_line --partial "no such option" + assert_line --partial "failed to parse docopt options for cmd cmd-2" + assert_line --partial "Usage: lets cmd [] [--config=]" } diff --git a/tests/command_options.bats b/tests/command_options.bats index 4d0c11ff..8bcfe3e3 100644 --- a/tests/command_options.bats +++ b/tests/command_options.bats @@ -122,7 +122,7 @@ setup() { run lets options-wrong-usage assert_failure - assert_line --index 1 "lets: failed to parse docopt options for cmd options-wrong-usage: no such option" + assert_line --index 1 "lets: failed to parse docopt options for cmd options-wrong-usage: unknown option or argument: options-wrong-usage" assert_line --index 2 "Usage: lets options-wrong-usage-xxx" }