From d5a9585c58ce4c56ac23f3c3c9c23d35a4b7f4a7 Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Fri, 6 Mar 2026 08:45:12 +0000 Subject: [PATCH] feat: grlx LSP server in Go LSP server for grlx recipe files (.grlx) providing: - Completion for ingredients, methods, properties, requisite types, and step ID references - Diagnostics for unknown ingredients/methods, missing required properties, unknown properties, and invalid requisite types - Hover documentation for all ingredients and methods with property tables - Full schema for all 6 grlx ingredients (cmd, file, group, pkg, service, user) with accurate properties from the grlx source --- .github/workflows/ci.yml | 28 +++ .gitignore | 2 + LICENSE | 12 ++ README.md | 91 +++++++++ flake.lock | 119 ++++++++++++ flake.nix | 97 ++++++++++ go.mod | 20 ++ go.sum | 83 ++++++++ internal/lsp/completion.go | 277 +++++++++++++++++++++++++++ internal/lsp/diagnostics.go | 183 ++++++++++++++++++ internal/lsp/handler.go | 145 ++++++++++++++ internal/lsp/handler_test.go | 189 +++++++++++++++++++ internal/lsp/hover.go | 128 +++++++++++++ internal/recipe/recipe.go | 241 ++++++++++++++++++++++++ internal/recipe/recipe_test.go | 187 ++++++++++++++++++ internal/schema/schema.go | 334 +++++++++++++++++++++++++++++++++ internal/schema/schema_test.go | 123 ++++++++++++ 17 files changed, 2259 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/lsp/completion.go create mode 100644 internal/lsp/diagnostics.go create mode 100644 internal/lsp/handler.go create mode 100644 internal/lsp/handler_test.go create mode 100644 internal/lsp/hover.go create mode 100644 internal/recipe/recipe.go create mode 100644 internal/recipe/recipe_test.go create mode 100644 internal/schema/schema.go create mode 100644 internal/schema/schema_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..18dd173 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.26" + - run: go build ./... + - run: go test -race ./... + - run: go vet ./... + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.26" + - run: go install honnef.co/go/tools/cmd/staticcheck@latest + - run: staticcheck ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..89bf1a2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +grlx-lsp +dist/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9424893 --- /dev/null +++ b/LICENSE @@ -0,0 +1,12 @@ +Copyright (C) 2023-2026 by gogrlx contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ad4f15d --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# grlx-lsp + +Language Server Protocol (LSP) server for [grlx](https://github.com/gogrlx/grlx) recipe files (`.grlx`). + +## Features + +- **Completion** — ingredient.method names, property keys, requisite types, step ID references +- **Diagnostics** — unknown ingredients/methods, missing required properties, unknown properties, invalid requisite types +- **Hover** — documentation for ingredients, methods, properties, and requisite types + +## Installation + +```bash +go install github.com/gogrlx/grlx-lsp/cmd/grlx-lsp@latest +``` + +## Editor Setup + +### Neovim (nvim-lspconfig) + +```lua +vim.api.nvim_create_autocmd({"BufRead", "BufNewFile"}, { + pattern = "*.grlx", + callback = function() + vim.bo.filetype = "grlx" + end, +}) + +local lspconfig = require("lspconfig") +local configs = require("lspconfig.configs") + +configs.grlx_lsp = { + default_config = { + cmd = { "grlx-lsp" }, + filetypes = { "grlx" }, + root_dir = lspconfig.util.find_git_ancestor, + settings = {}, + }, +} + +lspconfig.grlx_lsp.setup({}) +``` + +### VS Code + +Create `.vscode/settings.json`: + +```json +{ + "files.associations": { + "*.grlx": "yaml" + } +} +``` + +Then configure a generic LSP client extension to run `grlx-lsp` for the `grlx` file type. + +## Supported Ingredients + +| Ingredient | Methods | +|-----------|---------| +| `cmd` | run | +| `file` | absent, append, cached, contains, content, directory, exists, managed, missing, prepend, symlink, touch | +| `group` | absent, exists, present | +| `pkg` | cleaned, group_installed, held, installed, key_managed, latest, purged, removed, repo_managed | +| `service` | disabled, enabled, masked, restarted, running, stopped, unmasked | +| `user` | absent, exists, present | + +## Recipe Format + +grlx recipes are YAML files with Go template support: + +```yaml +include: + - apache + - .dev + +steps: + install nginx: + pkg.installed: + - name: nginx + start nginx: + service.running: + - name: nginx + - requisites: + - require: install nginx +``` + +## License + +0BSD diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..1926184 --- /dev/null +++ b/flake.lock @@ -0,0 +1,119 @@ +{ + "nodes": { + "crane": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1704819371, + "narHash": "sha256-oFUfPWrWGQTZaCM3byxwYwrMLwshDxVGOrMH5cVP/X8=", + "owner": "ipetkov", + "repo": "crane", + "rev": "5c234301a1277e4cc759c23a2a7a00a06ddd7111", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1704982712, + "narHash": "sha256-2Ptt+9h8dczgle2Oo6z5ni5rt/uLMG47UFTR1ry/wgg=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "07f6395285469419cf9d078f59b5b49993198c00", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1705309234, + "narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1705303754, + "narHash": "sha256-loWkd7lUzSvGBU9xnva37iPB2rr5ulq1qBLT44KjzGA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e0629618b4b419a47e2c8a3cab223e2a7f3a8f97", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-lib": { + "locked": { + "dir": "lib", + "lastModified": 1703961334, + "narHash": "sha256-M1mV/Cq+pgjk0rt6VxoyyD+O8cOUiai8t9Q6Yyq4noY=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b0d36bd0a420ecee3bc916c91886caca87c894e9", + "type": "github" + }, + "original": { + "dir": "lib", + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "crane": "crane", + "flake-parts": "flake-parts", + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..f2c0e0c --- /dev/null +++ b/flake.nix @@ -0,0 +1,97 @@ +{ + description = "A flake for grlx-nom, with Hercules CI support"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + + crane = { + url = "github:ipetkov/crane"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + flake-utils.url = "github:numtide/flake-utils"; + flake-parts.url = "github:hercules-ci/flake-parts"; + }; + + outputs = inputs @ { + self, + flake-parts, + nixpkgs, + crane, + flake-utils, + ... + }: + flake-parts.lib.mkFlake {inherit inputs;} { + systems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"]; + perSystem = { + pkgs, + system, + ... + }: let + pkgs = import nixpkgs { + inherit system; + }; + craneLib = crane.lib.${system}; + commonArgs = { + src = craneLib.cleanCargoSource (craneLib.path ./.); + buildInputs = with pkgs; + [ + openssl + pkg-config + # Add additional build inputs here + ] + ++ lib.optionals stdenv.isDarwin [ + # Additional darwin specific inputs can be set here + libiconv + ]; + }; + cargoArtifacts = craneLib.buildDepsOnly (commonArgs + // { + pname = "grlx-nom-deps"; + }); + + grlx-nom-clippy = craneLib.cargoClippy (commonArgs + // { + inherit cargoArtifacts; + cargoClippyExtraArgs = "--all-targets -- --deny warnings"; + }); + + grlx-nom-nextest = craneLib.cargoNextest (commonArgs + // { + inherit cargoArtifacts; + }); + + grlx-nom = craneLib.buildPackage (commonArgs + // { + inherit cargoArtifacts; + }); + in { + checks = { + inherit + grlx-nom + grlx-nom-clippy + grlx-nom-nextest + ; + }; + formatter = pkgs.alejandra; + packages.default = grlx-nom; + + apps.default = flake-utils.lib.mkApp { + drv = grlx-nom; + }; + + devShells.default = pkgs.mkShell { + # Additional dev-shell environment variables can be set directly + # MY_CUSTOM_DEVELOPMENT_VAR = "something else"; + + # Extra inputs can be added here + nativeBuildInputs = [ + commonArgs.buildInputs + pkgs.cargo + pkgs.rustc + pkgs.rust-analyzer + ]; + }; + }; + }; +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6c980c9 --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module github.com/gogrlx/grlx-lsp + +go 1.26.1 + +require ( + go.lsp.dev/jsonrpc2 v0.10.0 + go.lsp.dev/protocol v0.12.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.3.4 // indirect + go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2 // indirect + go.lsp.dev/uri v0.3.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.8.0 // indirect + go.uber.org/zap v1.21.0 // indirect + golang.org/x/sys v0.41.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ba7c46e --- /dev/null +++ b/go.sum @@ -0,0 +1,83 @@ +github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.3.4 h1:WM4IBnxH8B9TakiM2QD5LyNl9JSndh88QbHqVC+Pauc= +github.com/segmentio/encoding v0.3.4/go.mod h1:n0JeuIqEQrQoPDGsjo8UNd1iA0U8d8+oHAA4E3G3OxM= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +go.lsp.dev/jsonrpc2 v0.10.0 h1:Pr/YcXJoEOTMc/b6OTmcR1DPJ3mSWl/SWiU1Cct6VmI= +go.lsp.dev/jsonrpc2 v0.10.0/go.mod h1:fmEzIdXPi/rf6d4uFcayi8HpFP1nBF99ERP1htC72Ac= +go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2 h1:hCzQgh6UcwbKgNSRurYWSqh8MufqRRPODRBblutn4TE= +go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2/go.mod h1:gtSHRuYfbCT0qnbLnovpie/WEmqyJ7T4n6VXiFMBtcw= +go.lsp.dev/protocol v0.12.0 h1:tNprUI9klQW5FAFVM4Sa+AbPFuVQByWhP1ttNUAjIWg= +go.lsp.dev/protocol v0.12.0/go.mod h1:Qb11/HgZQ72qQbeyPfJbu3hZBH23s1sr4st8czGeDMQ= +go.lsp.dev/uri v0.3.0 h1:KcZJmh6nFIBeJzTugn5JTU6OOyG0lDOo3R9KwTxTYbo= +go.lsp.dev/uri v0.3.0/go.mod h1:P5sbO1IQR+qySTWOCnhnK7phBx+W3zbLqSMDJNTw88I= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= +go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8= +go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= +go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8= +go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211110154304-99a53858aa08/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/lsp/completion.go b/internal/lsp/completion.go new file mode 100644 index 0000000..f3700eb --- /dev/null +++ b/internal/lsp/completion.go @@ -0,0 +1,277 @@ +package lsp + +import ( + "context" + "encoding/json" + "strings" + + "go.lsp.dev/jsonrpc2" + "go.lsp.dev/protocol" + + "github.com/gogrlx/grlx-lsp/internal/schema" +) + +func (h *Handler) handleCompletion(_ context.Context, reply jsonrpc2.Replier, req jsonrpc2.Request) error { + ctx := context.Background() + + var params protocol.CompletionParams + if err := json.Unmarshal(req.Params(), ¶ms); err != nil { + return reply(ctx, nil, err) + } + + doc := h.getDocument(string(params.TextDocument.URI)) + if doc == nil { + return reply(ctx, &protocol.CompletionList{}, nil) + } + + line := lineAt(doc.content, int(params.Position.Line)) + col := int(params.Position.Character) + if col > len(line) { + col = len(line) + } + prefix := strings.TrimSpace(line[:col]) + + var items []protocol.CompletionItem + + switch { + case isTopLevel(doc.content, int(params.Position.Line)): + items = h.completeTopLevel(prefix) + case isInRequisites(line): + items = h.completeRequisiteTypes(prefix) + case isInRequisiteValue(doc.content, int(params.Position.Line)): + items = h.completeStepIDs(doc) + case isPropertyPosition(line): + items = h.completeProperties(doc, int(params.Position.Line)) + default: + items = h.completeIngredientMethod(prefix) + } + + return reply(ctx, &protocol.CompletionList{ + IsIncomplete: false, + Items: items, + }, nil) +} + +func (h *Handler) completeTopLevel(_ string) []protocol.CompletionItem { + var items []protocol.CompletionItem + for _, key := range schema.TopLevelKeys { + items = append(items, protocol.CompletionItem{ + Label: key, + Kind: protocol.CompletionItemKindKeyword, + }) + } + return items +} + +func (h *Handler) completeIngredientMethod(prefix string) []protocol.CompletionItem { + var items []protocol.CompletionItem + + // If prefix contains a dot, complete methods for that ingredient + if dotIdx := strings.Index(prefix, "."); dotIdx >= 0 { + ingName := prefix[:dotIdx] + ing := h.registry.FindIngredient(ingName) + if ing != nil { + for _, m := range ing.Methods { + items = append(items, protocol.CompletionItem{ + Label: ingName + "." + m.Name, + Kind: protocol.CompletionItemKindFunction, + Detail: m.Description, + Documentation: buildMethodDoc(ing, &m), + }) + } + } + return items + } + + // Otherwise, complete all ingredient.method combos + for _, name := range h.registry.AllDottedNames() { + parts := strings.SplitN(name, ".", 2) + ing := h.registry.FindIngredient(parts[0]) + m := h.registry.FindMethod(parts[0], parts[1]) + detail := "" + if m != nil { + detail = m.Description + } + doc := "" + if ing != nil && m != nil { + doc = buildMethodDoc(ing, m) + } + items = append(items, protocol.CompletionItem{ + Label: name, + Kind: protocol.CompletionItemKindFunction, + Detail: detail, + Documentation: doc, + }) + } + return items +} + +func (h *Handler) completeProperties(doc *document, line int) []protocol.CompletionItem { + var items []protocol.CompletionItem + + // Find which step this line belongs to + step := h.findStepForLine(doc, line) + if step == nil { + return items + } + + m := h.registry.FindMethod(step.Ingredient, step.Method) + if m == nil { + return items + } + + // Collect already-used properties + used := make(map[string]bool) + for _, p := range step.Properties { + used[p.Key] = true + } + + for _, prop := range m.Properties { + if used[prop.Key] { + continue + } + detail := prop.Type + if prop.Required { + detail += " (required)" + } + items = append(items, protocol.CompletionItem{ + Label: "- " + prop.Key + ": ", + InsertText: "- " + prop.Key + ": ", + Kind: protocol.CompletionItemKindProperty, + Detail: detail, + }) + } + + // Also offer requisites + if !used["requisites"] { + items = append(items, protocol.CompletionItem{ + Label: "- requisites:", + Kind: protocol.CompletionItemKindKeyword, + Detail: "Step dependencies", + }) + } + + return items +} + +func (h *Handler) completeRequisiteTypes(_ string) []protocol.CompletionItem { + var items []protocol.CompletionItem + for _, rt := range schema.AllRequisiteTypes { + items = append(items, protocol.CompletionItem{ + Label: "- " + rt.Name + ": ", + Kind: protocol.CompletionItemKindEnum, + Detail: rt.Description, + }) + } + return items +} + +func (h *Handler) completeStepIDs(doc *document) []protocol.CompletionItem { + var items []protocol.CompletionItem + if doc.recipe == nil { + return items + } + for _, id := range doc.recipe.StepIDs() { + items = append(items, protocol.CompletionItem{ + Label: id, + Kind: protocol.CompletionItemKindReference, + }) + } + return items +} + +func buildMethodDoc(ing *schema.Ingredient, m *schema.Method) string { + var sb strings.Builder + sb.WriteString(ing.Name + "." + m.Name) + if m.Description != "" { + sb.WriteString(" — " + m.Description) + } + if len(m.Properties) > 0 { + sb.WriteString("\n\nProperties:\n") + for _, p := range m.Properties { + marker := " " + if p.Required { + marker = "* " + } + sb.WriteString(marker + p.Key + " (" + p.Type + ")") + if p.Description != "" { + sb.WriteString(" — " + p.Description) + } + sb.WriteString("\n") + } + } + return sb.String() +} + +// Heuristics for context detection + +func isTopLevel(content string, line int) bool { + lines := strings.Split(content, "\n") + if line < 0 || line >= len(lines) { + return false + } + l := lines[line] + // Top-level if no leading whitespace or empty + return len(l) == 0 || (len(strings.TrimLeft(l, " \t")) == len(l)) +} + +func isInRequisites(line string) bool { + trimmed := strings.TrimSpace(line) + // Inside a requisites block: indented under "- requisites:" + return strings.HasPrefix(trimmed, "- require") || + strings.HasPrefix(trimmed, "- onchanges") || + strings.HasPrefix(trimmed, "- onfail") +} + +func isInRequisiteValue(content string, line int) bool { + lines := strings.Split(content, "\n") + // Look backwards for a requisite condition key + for i := line; i >= 0 && i >= line-5; i-- { + trimmed := strings.TrimSpace(lines[i]) + if strings.HasPrefix(trimmed, "- require:") || + strings.HasPrefix(trimmed, "- require_any:") || + strings.HasPrefix(trimmed, "- onchanges:") || + strings.HasPrefix(trimmed, "- onchanges_any:") || + strings.HasPrefix(trimmed, "- onfail:") || + strings.HasPrefix(trimmed, "- onfail_any:") { + return true + } + } + return false +} + +func isPropertyPosition(line string) bool { + trimmed := strings.TrimSpace(line) + // Property lines start with "- " and are indented + indent := len(line) - len(strings.TrimLeft(line, " ")) + return indent >= 4 && (strings.HasPrefix(trimmed, "- ") || trimmed == "-" || trimmed == "") +} + +func (h *Handler) findStepForLine(doc *document, line int) *struct { + Ingredient string + Method string + Properties []struct{ Key string } +} { + if doc.recipe == nil { + return nil + } + // Find the step whose method node is on or before this line + for i := len(doc.recipe.Steps) - 1; i >= 0; i-- { + s := &doc.recipe.Steps[i] + if s.MethodNode != nil && s.MethodNode.Line-1 <= line { + result := &struct { + Ingredient string + Method string + Properties []struct{ Key string } + }{ + Ingredient: s.Ingredient, + Method: s.Method, + } + for _, p := range s.Properties { + result.Properties = append(result.Properties, struct{ Key string }{Key: p.Key}) + } + return result + } + } + return nil +} diff --git a/internal/lsp/diagnostics.go b/internal/lsp/diagnostics.go new file mode 100644 index 0000000..68f9dac --- /dev/null +++ b/internal/lsp/diagnostics.go @@ -0,0 +1,183 @@ +package lsp + +import ( + "context" + + "go.lsp.dev/protocol" + "gopkg.in/yaml.v3" + + "github.com/gogrlx/grlx-lsp/internal/recipe" + "github.com/gogrlx/grlx-lsp/internal/schema" +) + +func (h *Handler) publishDiagnostics(ctx context.Context, uri string) { + doc := h.getDocument(uri) + if doc == nil || h.conn == nil { + return + } + + diags := h.diagnose(doc) + + _ = h.conn.Notify(ctx, "textDocument/publishDiagnostics", protocol.PublishDiagnosticsParams{ + URI: protocol.DocumentURI(uri), + Diagnostics: diags, + }) +} + +func (h *Handler) diagnose(doc *document) []protocol.Diagnostic { + var diags []protocol.Diagnostic + + if doc.recipe == nil { + return diags + } + + // Report parse errors + for _, e := range doc.recipe.Errors { + diags = append(diags, protocol.Diagnostic{ + Range: pointRange(e.Line-1, e.Col-1), + Severity: protocol.DiagnosticSeverityError, + Source: "grlx-lsp", + Message: e.Message, + }) + } + + stepIDs := make(map[string]bool) + for _, s := range doc.recipe.Steps { + stepIDs[s.ID] = true + } + + for _, s := range doc.recipe.Steps { + if s.Ingredient == "" { + continue + } + + ing := h.registry.FindIngredient(s.Ingredient) + if ing == nil { + diags = append(diags, protocol.Diagnostic{ + Range: yamlNodeRange(s.MethodNode), + Severity: protocol.DiagnosticSeverityError, + Source: "grlx-lsp", + Message: "unknown ingredient: " + s.Ingredient, + }) + continue + } + + m := h.registry.FindMethod(s.Ingredient, s.Method) + if m == nil { + diags = append(diags, protocol.Diagnostic{ + Range: yamlNodeRange(s.MethodNode), + Severity: protocol.DiagnosticSeverityError, + Source: "grlx-lsp", + Message: "unknown method: " + s.Ingredient + "." + s.Method, + }) + continue + } + + diags = append(diags, checkRequired(s, m)...) + diags = append(diags, checkUnknown(s, m)...) + + // Validate requisite types and references + for _, req := range s.Requisites { + if !isValidRequisiteType(req.Condition) { + diags = append(diags, protocol.Diagnostic{ + Range: yamlNodeRange(req.Node), + Severity: protocol.DiagnosticSeverityError, + Source: "grlx-lsp", + Message: "unknown requisite type: " + req.Condition, + }) + } + for _, ref := range req.StepIDs { + if !stepIDs[ref] { + diags = append(diags, protocol.Diagnostic{ + Range: yamlNodeRange(req.Node), + Severity: protocol.DiagnosticSeverityWarning, + Source: "grlx-lsp", + Message: "reference to unknown step: " + ref + " (may be defined in an included recipe)", + }) + } + } + } + } + + return diags +} + +func checkRequired(s recipe.Step, m *schema.Method) []protocol.Diagnostic { + var diags []protocol.Diagnostic + propKeys := make(map[string]bool) + for _, p := range s.Properties { + propKeys[p.Key] = true + } + for _, prop := range m.Properties { + if prop.Required && !propKeys[prop.Key] { + diags = append(diags, protocol.Diagnostic{ + Range: yamlNodeRange(s.MethodNode), + Severity: protocol.DiagnosticSeverityWarning, + Source: "grlx-lsp", + Message: "missing required property: " + prop.Key, + }) + } + } + return diags +} + +func checkUnknown(s recipe.Step, m *schema.Method) []protocol.Diagnostic { + var diags []protocol.Diagnostic + validProps := make(map[string]bool) + for _, prop := range m.Properties { + validProps[prop.Key] = true + } + validProps["requisites"] = true + for _, p := range s.Properties { + if !validProps[p.Key] { + diags = append(diags, protocol.Diagnostic{ + Range: yamlNodeRange(p.KeyNode), + Severity: protocol.DiagnosticSeverityWarning, + Source: "grlx-lsp", + Message: "unknown property: " + p.Key + " for " + s.Ingredient + "." + s.Method, + }) + } + } + return diags +} + +func isValidRequisiteType(name string) bool { + for _, rt := range schema.AllRequisiteTypes { + if rt.Name == name { + return true + } + } + return false +} + +func pointRange(line, col int) protocol.Range { + if line < 0 { + line = 0 + } + if col < 0 { + col = 0 + } + return protocol.Range{ + Start: protocol.Position{Line: uint32(line), Character: uint32(col)}, + End: protocol.Position{Line: uint32(line), Character: uint32(col + 1)}, + } +} + +func yamlNodeRange(node *yaml.Node) protocol.Range { + if node == nil { + return pointRange(0, 0) + } + line := node.Line - 1 + col := node.Column - 1 + endCol := col + len(node.Value) + if line < 0 { + line = 0 + } + if col < 0 { + col = 0 + } + return protocol.Range{ + Start: protocol.Position{Line: uint32(line), Character: uint32(col)}, + End: protocol.Position{Line: uint32(line), Character: uint32(endCol)}, + } +} diff --git a/internal/lsp/handler.go b/internal/lsp/handler.go new file mode 100644 index 0000000..f6d11d5 --- /dev/null +++ b/internal/lsp/handler.go @@ -0,0 +1,145 @@ +// Package lsp implements the Language Server Protocol handler for grlx recipes. +package lsp + +import ( + "context" + "encoding/json" + "strings" + "sync" + + "go.lsp.dev/jsonrpc2" + "go.lsp.dev/protocol" + + "github.com/gogrlx/grlx-lsp/internal/recipe" + "github.com/gogrlx/grlx-lsp/internal/schema" +) + +// Handler implements the LSP server. +type Handler struct { + conn jsonrpc2.Conn + registry *schema.Registry + mu sync.RWMutex + docs map[string]*document // URI -> document +} + +type document struct { + content string + recipe *recipe.Recipe +} + +// NewHandler creates a new LSP handler. +func NewHandler(registry *schema.Registry) *Handler { + return &Handler{ + registry: registry, + docs: make(map[string]*document), + } +} + +// SetConn sets the jsonrpc2 connection for sending notifications. +func (h *Handler) SetConn(conn jsonrpc2.Conn) { + h.conn = conn +} + +// Handle dispatches LSP requests. +func (h *Handler) Handle(ctx context.Context, reply jsonrpc2.Replier, req jsonrpc2.Request) error { + switch req.Method() { + case "initialize": + return h.handleInitialize(ctx, reply, req) + case "initialized": + return reply(ctx, nil, nil) + case "shutdown": + return reply(ctx, nil, nil) + case "exit": + return reply(ctx, nil, nil) + case "textDocument/didOpen": + return h.handleDidOpen(ctx, reply, req) + case "textDocument/didChange": + return h.handleDidChange(ctx, reply, req) + case "textDocument/didClose": + return h.handleDidClose(ctx, reply, req) + case "textDocument/didSave": + return reply(ctx, nil, nil) + case "textDocument/completion": + return h.handleCompletion(ctx, reply, req) + case "textDocument/hover": + return h.handleHover(ctx, reply, req) + case "textDocument/diagnostic": + return reply(ctx, nil, nil) + default: + return reply(ctx, nil, jsonrpc2.NewError(jsonrpc2.MethodNotFound, "method not supported: "+req.Method())) + } +} + +func (h *Handler) handleInitialize(_ context.Context, reply jsonrpc2.Replier, _ jsonrpc2.Request) error { + return reply(context.Background(), protocol.InitializeResult{ + Capabilities: protocol.ServerCapabilities{ + TextDocumentSync: protocol.TextDocumentSyncOptions{ + OpenClose: true, + Change: protocol.TextDocumentSyncKindFull, + }, + CompletionProvider: &protocol.CompletionOptions{ + TriggerCharacters: []string{".", ":", "-", " "}, + }, + HoverProvider: true, + }, + ServerInfo: &protocol.ServerInfo{ + Name: "grlx-lsp", + Version: "0.1.0", + }, + }, nil) +} + +func (h *Handler) handleDidOpen(ctx context.Context, reply jsonrpc2.Replier, req jsonrpc2.Request) error { + var params protocol.DidOpenTextDocumentParams + if err := json.Unmarshal(req.Params(), ¶ms); err != nil { + return reply(ctx, nil, err) + } + h.updateDocument(string(params.TextDocument.URI), params.TextDocument.Text) + h.publishDiagnostics(ctx, string(params.TextDocument.URI)) + return reply(ctx, nil, nil) +} + +func (h *Handler) handleDidChange(ctx context.Context, reply jsonrpc2.Replier, req jsonrpc2.Request) error { + var params protocol.DidChangeTextDocumentParams + if err := json.Unmarshal(req.Params(), ¶ms); err != nil { + return reply(ctx, nil, err) + } + if len(params.ContentChanges) > 0 { + h.updateDocument(string(params.TextDocument.URI), params.ContentChanges[len(params.ContentChanges)-1].Text) + h.publishDiagnostics(ctx, string(params.TextDocument.URI)) + } + return reply(ctx, nil, nil) +} + +func (h *Handler) handleDidClose(_ context.Context, reply jsonrpc2.Replier, req jsonrpc2.Request) error { + var params protocol.DidCloseTextDocumentParams + if err := json.Unmarshal(req.Params(), ¶ms); err != nil { + return reply(context.Background(), nil, err) + } + h.mu.Lock() + delete(h.docs, string(params.TextDocument.URI)) + h.mu.Unlock() + return reply(context.Background(), nil, nil) +} + +func (h *Handler) updateDocument(uri, content string) { + r := recipe.Parse([]byte(content)) + h.mu.Lock() + h.docs[uri] = &document{content: content, recipe: r} + h.mu.Unlock() +} + +func (h *Handler) getDocument(uri string) *document { + h.mu.RLock() + defer h.mu.RUnlock() + return h.docs[uri] +} + +// lineAt returns the text of the given line (0-indexed). +func lineAt(content string, line int) string { + lines := strings.Split(content, "\n") + if line < 0 || line >= len(lines) { + return "" + } + return lines[line] +} diff --git a/internal/lsp/handler_test.go b/internal/lsp/handler_test.go new file mode 100644 index 0000000..ed85029 --- /dev/null +++ b/internal/lsp/handler_test.go @@ -0,0 +1,189 @@ +package lsp + +import ( + "testing" + + "github.com/gogrlx/grlx-lsp/internal/recipe" + "github.com/gogrlx/grlx-lsp/internal/schema" +) + +func TestDiagnoseUnknownIngredient(t *testing.T) { + h := NewHandler(schema.DefaultRegistry()) + doc := &document{ + content: `steps: + bad step: + bogus.method: + - name: foo`, + recipe: recipe.Parse([]byte(`steps: + bad step: + bogus.method: + - name: foo`)), + } + + diags := h.diagnose(doc) + found := false + for _, d := range diags { + if d.Message == "unknown ingredient: bogus" { + found = true + } + } + if !found { + t.Errorf("expected unknown ingredient diagnostic, got: %v", diags) + } +} + +func TestDiagnoseUnknownMethod(t *testing.T) { + h := NewHandler(schema.DefaultRegistry()) + src := `steps: + bad step: + file.nonexistent: + - name: foo` + doc := &document{ + content: src, + recipe: recipe.Parse([]byte(src)), + } + + diags := h.diagnose(doc) + found := false + for _, d := range diags { + if d.Message == "unknown method: file.nonexistent" { + found = true + } + } + if !found { + t.Errorf("expected unknown method diagnostic, got: %v", diags) + } +} + +func TestDiagnoseMissingRequired(t *testing.T) { + h := NewHandler(schema.DefaultRegistry()) + // file.managed requires both name and source + src := `steps: + manage file: + file.managed: + - user: root` + doc := &document{ + content: src, + recipe: recipe.Parse([]byte(src)), + } + + diags := h.diagnose(doc) + foundName := false + foundSource := false + for _, d := range diags { + if d.Message == "missing required property: name" { + foundName = true + } + if d.Message == "missing required property: source" { + foundSource = true + } + } + if !foundName { + t.Error("expected diagnostic for missing required property: name") + } + if !foundSource { + t.Error("expected diagnostic for missing required property: source") + } +} + +func TestDiagnoseUnknownProperty(t *testing.T) { + h := NewHandler(schema.DefaultRegistry()) + src := `steps: + my step: + file.absent: + - name: /tmp/foo + - bogusprop: bar` + doc := &document{ + content: src, + recipe: recipe.Parse([]byte(src)), + } + + diags := h.diagnose(doc) + found := false + for _, d := range diags { + if d.Message == "unknown property: bogusprop for file.absent" { + found = true + } + } + if !found { + t.Errorf("expected unknown property diagnostic, got: %v", diags) + } +} + +func TestDiagnoseValidRecipe(t *testing.T) { + h := NewHandler(schema.DefaultRegistry()) + src := `steps: + install nginx: + pkg.installed: + - name: nginx` + doc := &document{ + content: src, + recipe: recipe.Parse([]byte(src)), + } + + diags := h.diagnose(doc) + if len(diags) != 0 { + t.Errorf("expected no diagnostics for valid recipe, got: %v", diags) + } +} + +func TestDiagnoseUnknownRequisiteType(t *testing.T) { + h := NewHandler(schema.DefaultRegistry()) + src := `steps: + first: + file.exists: + - name: /tmp/a + second: + file.exists: + - name: /tmp/b + - requisites: + - bogus_req: first` + doc := &document{ + content: src, + recipe: recipe.Parse([]byte(src)), + } + + diags := h.diagnose(doc) + found := false + for _, d := range diags { + if d.Message == "unknown requisite type: bogus_req" { + found = true + } + } + if !found { + t.Errorf("expected unknown requisite type diagnostic, got: %v", diags) + } +} + +func TestLineAt(t *testing.T) { + content := "line0\nline1\nline2" + if got := lineAt(content, 0); got != "line0" { + t.Errorf("lineAt(0) = %q, want %q", got, "line0") + } + if got := lineAt(content, 2); got != "line2" { + t.Errorf("lineAt(2) = %q, want %q", got, "line2") + } + if got := lineAt(content, 99); got != "" { + t.Errorf("lineAt(99) = %q, want empty", got) + } +} + +func TestWordAtPosition(t *testing.T) { + tests := []struct { + line string + col int + want string + }{ + {" file.managed:", 8, "file.managed"}, + {" - name: foo", 6, "name"}, + {" - require: step one", 10, "require"}, + {"", 0, ""}, + {" pkg.installed:", 5, "pkg.installed"}, + } + for _, tt := range tests { + got := wordAtPosition(tt.line, tt.col) + if got != tt.want { + t.Errorf("wordAtPosition(%q, %d) = %q, want %q", tt.line, tt.col, got, tt.want) + } + } +} diff --git a/internal/lsp/hover.go b/internal/lsp/hover.go new file mode 100644 index 0000000..b7e92d8 --- /dev/null +++ b/internal/lsp/hover.go @@ -0,0 +1,128 @@ +package lsp + +import ( + "context" + "encoding/json" + "strings" + + "go.lsp.dev/jsonrpc2" + "go.lsp.dev/protocol" + + "github.com/gogrlx/grlx-lsp/internal/schema" +) + +func (h *Handler) handleHover(_ context.Context, reply jsonrpc2.Replier, req jsonrpc2.Request) error { + ctx := context.Background() + + var params protocol.HoverParams + if err := json.Unmarshal(req.Params(), ¶ms); err != nil { + return reply(ctx, nil, err) + } + + doc := h.getDocument(string(params.TextDocument.URI)) + if doc == nil { + return reply(ctx, nil, nil) + } + + line := lineAt(doc.content, int(params.Position.Line)) + word := wordAtPosition(line, int(params.Position.Character)) + + if word == "" { + return reply(ctx, nil, nil) + } + + // Check if it's an ingredient.method reference + if strings.Contains(word, ".") { + parts := strings.SplitN(word, ".", 2) + if len(parts) == 2 { + m := h.registry.FindMethod(parts[0], parts[1]) + ing := h.registry.FindIngredient(parts[0]) + if m != nil && ing != nil { + return reply(ctx, &protocol.Hover{ + Contents: protocol.MarkupContent{ + Kind: protocol.Markdown, + Value: buildMethodMarkdown(ing.Name, m), + }, + }, nil) + } + } + } + + // Check if it's just an ingredient name + ing := h.registry.FindIngredient(word) + if ing != nil { + var methods []string + for _, m := range ing.Methods { + methods = append(methods, ing.Name+"."+m.Name) + } + return reply(ctx, &protocol.Hover{ + Contents: protocol.MarkupContent{ + Kind: protocol.Markdown, + Value: "**" + ing.Name + "** — " + ing.Description + "\n\nMethods: `" + strings.Join(methods, "`, `") + "`", + }, + }, nil) + } + + // Check if it's a requisite type + for _, rt := range h.registry.RequisiteTypes { + if rt.Name == word { + return reply(ctx, &protocol.Hover{ + Contents: protocol.MarkupContent{ + Kind: protocol.Markdown, + Value: "**" + rt.Name + "** — " + rt.Description, + }, + }, nil) + } + } + + return reply(ctx, nil, nil) +} + +func buildMethodMarkdown(ingredient string, m *schema.Method) string { + var sb strings.Builder + sb.WriteString("### " + ingredient + "." + m.Name + "\n\n") + if m.Description != "" { + sb.WriteString(m.Description + "\n\n") + } + if len(m.Properties) > 0 { + sb.WriteString("| Property | Type | Required | Description |\n") + sb.WriteString("|----------|------|----------|-------------|\n") + for _, p := range m.Properties { + req := "" + if p.Required { + req = "yes" + } + desc := p.Description + if desc == "" { + desc = "—" + } + sb.WriteString("| `" + p.Key + "` | " + p.Type + " | " + req + " | " + desc + " |\n") + } + } + return sb.String() +} + +func wordAtPosition(line string, col int) string { + if col > len(line) { + col = len(line) + } + + start := col + for start > 0 && isWordChar(line[start-1]) { + start-- + } + + end := col + for end < len(line) && isWordChar(line[end]) { + end++ + } + + return line[start:end] +} + +func isWordChar(b byte) bool { + return (b >= 'a' && b <= 'z') || + (b >= 'A' && b <= 'Z') || + (b >= '0' && b <= '9') || + b == '_' || b == '.' || b == '-' +} diff --git a/internal/recipe/recipe.go b/internal/recipe/recipe.go new file mode 100644 index 0000000..26c4abc --- /dev/null +++ b/internal/recipe/recipe.go @@ -0,0 +1,241 @@ +// Package recipe parses .grlx recipe files and provides structured access +// to their contents for diagnostics, completion, and hover. +package recipe + +import ( + "strings" + + "gopkg.in/yaml.v3" +) + +// Recipe represents a parsed .grlx file. +type Recipe struct { + Root *yaml.Node + Includes []Include + Steps []Step + Errors []ParseError +} + +// Include represents a single include directive. +type Include struct { + Value string + Node *yaml.Node +} + +// Step represents a single step in the recipe. +type Step struct { + ID string + IDNode *yaml.Node + Ingredient string + Method string + MethodNode *yaml.Node + Properties []PropertyEntry + Requisites []RequisiteEntry +} + +// PropertyEntry is a key-value pair in a step's property list. +type PropertyEntry struct { + Key string + Value interface{} + KeyNode *yaml.Node + ValueNode *yaml.Node +} + +// RequisiteEntry is a parsed requisite condition. +type RequisiteEntry struct { + Condition string + StepIDs []string + Node *yaml.Node +} + +// ParseError represents a recipe parse error with location. +type ParseError struct { + Message string + Line int + Col int +} + +// Parse parses raw YAML bytes into a Recipe. +func Parse(data []byte) *Recipe { + r := &Recipe{} + + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil { + r.Errors = append(r.Errors, ParseError{Message: "invalid YAML: " + err.Error(), Line: 0, Col: 0}) + return r + } + + if doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 { + return r + } + + r.Root = doc.Content[0] + + if r.Root.Kind != yaml.MappingNode { + r.Errors = append(r.Errors, ParseError{Message: "recipe must be a YAML mapping", Line: r.Root.Line, Col: r.Root.Column}) + return r + } + + for i := 0; i+1 < len(r.Root.Content); i += 2 { + keyNode := r.Root.Content[i] + valNode := r.Root.Content[i+1] + + switch keyNode.Value { + case "include": + r.parseIncludes(valNode) + case "steps": + r.parseSteps(valNode) + default: + r.Errors = append(r.Errors, ParseError{ + Message: "unknown top-level key: " + keyNode.Value, + Line: keyNode.Line, + Col: keyNode.Column, + }) + } + } + + return r +} + +func (r *Recipe) parseIncludes(node *yaml.Node) { + if node.Kind != yaml.SequenceNode { + r.Errors = append(r.Errors, ParseError{ + Message: "include must be a list", + Line: node.Line, + Col: node.Column, + }) + return + } + for _, item := range node.Content { + if item.Kind == yaml.ScalarNode { + r.Includes = append(r.Includes, Include{Value: item.Value, Node: item}) + } + } +} + +func (r *Recipe) parseSteps(node *yaml.Node) { + if node.Kind != yaml.MappingNode { + r.Errors = append(r.Errors, ParseError{ + Message: "steps must be a mapping", + Line: node.Line, + Col: node.Column, + }) + return + } + + for i := 0; i+1 < len(node.Content); i += 2 { + stepIDNode := node.Content[i] + stepBodyNode := node.Content[i+1] + r.parseStep(stepIDNode, stepBodyNode) + } +} + +func (r *Recipe) parseStep(idNode, bodyNode *yaml.Node) { + if bodyNode.Kind != yaml.MappingNode { + r.Errors = append(r.Errors, ParseError{ + Message: "step body must be a mapping", + Line: bodyNode.Line, + Col: bodyNode.Column, + }) + return + } + + if len(bodyNode.Content) < 2 { + r.Errors = append(r.Errors, ParseError{ + Message: "step must have exactly one ingredient.method key", + Line: bodyNode.Line, + Col: bodyNode.Column, + }) + return + } + + methodKeyNode := bodyNode.Content[0] + methodValNode := bodyNode.Content[1] + + parts := strings.SplitN(methodKeyNode.Value, ".", 2) + ingredient := "" + method := "" + if len(parts) == 2 { + ingredient = parts[0] + method = parts[1] + } else { + r.Errors = append(r.Errors, ParseError{ + Message: "step key must be in the form ingredient.method, got: " + methodKeyNode.Value, + Line: methodKeyNode.Line, + Col: methodKeyNode.Column, + }) + } + + step := Step{ + ID: idNode.Value, + IDNode: idNode, + Ingredient: ingredient, + Method: method, + MethodNode: methodKeyNode, + } + + // The value should be a sequence of mappings (property list) + if methodValNode.Kind == yaml.SequenceNode { + for _, item := range methodValNode.Content { + if item.Kind == yaml.MappingNode { + for j := 0; j+1 < len(item.Content); j += 2 { + k := item.Content[j] + v := item.Content[j+1] + if k.Value == "requisites" { + step.Requisites = parseRequisites(v) + } else { + step.Properties = append(step.Properties, PropertyEntry{ + Key: k.Value, + KeyNode: k, + ValueNode: v, + }) + } + } + } + } + } + + r.Steps = append(r.Steps, step) +} + +func parseRequisites(node *yaml.Node) []RequisiteEntry { + var reqs []RequisiteEntry + if node.Kind != yaml.SequenceNode { + return reqs + } + for _, item := range node.Content { + if item.Kind != yaml.MappingNode { + continue + } + for i := 0; i+1 < len(item.Content); i += 2 { + condition := item.Content[i].Value + valNode := item.Content[i+1] + var stepIDs []string + switch valNode.Kind { + case yaml.ScalarNode: + stepIDs = append(stepIDs, valNode.Value) + case yaml.SequenceNode: + for _, s := range valNode.Content { + if s.Kind == yaml.ScalarNode { + stepIDs = append(stepIDs, s.Value) + } + } + } + reqs = append(reqs, RequisiteEntry{ + Condition: condition, + StepIDs: stepIDs, + Node: item.Content[i], + }) + } + } + return reqs +} + +// StepIDs returns all step IDs defined in this recipe. +func (r *Recipe) StepIDs() []string { + var ids []string + for _, s := range r.Steps { + ids = append(ids, s.ID) + } + return ids +} diff --git a/internal/recipe/recipe_test.go b/internal/recipe/recipe_test.go new file mode 100644 index 0000000..2226b4a --- /dev/null +++ b/internal/recipe/recipe_test.go @@ -0,0 +1,187 @@ +package recipe + +import "testing" + +func TestParseSimpleRecipe(t *testing.T) { + data := []byte(` +include: + - apache + - .dev + +steps: + install nginx: + pkg.installed: + - name: nginx + start nginx: + service.running: + - name: nginx + - requisites: + - require: install nginx +`) + r := Parse(data) + + if len(r.Errors) > 0 { + t.Fatalf("unexpected parse errors: %v", r.Errors) + } + + if len(r.Includes) != 2 { + t.Fatalf("expected 2 includes, got %d", len(r.Includes)) + } + if r.Includes[0].Value != "apache" { + t.Errorf("include[0] = %q, want %q", r.Includes[0].Value, "apache") + } + if r.Includes[1].Value != ".dev" { + t.Errorf("include[1] = %q, want %q", r.Includes[1].Value, ".dev") + } + + if len(r.Steps) != 2 { + t.Fatalf("expected 2 steps, got %d", len(r.Steps)) + } + + s := r.Steps[0] + if s.ID != "install nginx" { + t.Errorf("step[0].ID = %q, want %q", s.ID, "install nginx") + } + if s.Ingredient != "pkg" { + t.Errorf("step[0].Ingredient = %q, want %q", s.Ingredient, "pkg") + } + if s.Method != "installed" { + t.Errorf("step[0].Method = %q, want %q", s.Method, "installed") + } + if len(s.Properties) != 1 || s.Properties[0].Key != "name" { + t.Errorf("step[0] expected name property, got %v", s.Properties) + } +} + +func TestParseInvalidYAML(t *testing.T) { + data := []byte(`{{{invalid`) + r := Parse(data) + + if len(r.Errors) == 0 { + t.Error("expected parse errors for invalid YAML") + } +} + +func TestParseUnknownTopLevel(t *testing.T) { + data := []byte(` +include: + - foo +bogus_key: bar +steps: {} +`) + r := Parse(data) + + found := false + for _, e := range r.Errors { + if e.Message == "unknown top-level key: bogus_key" { + found = true + } + } + if !found { + t.Error("expected error about unknown top-level key") + } +} + +func TestParseBadMethodKey(t *testing.T) { + data := []byte(` +steps: + bad step: + nomethod: + - name: foo +`) + r := Parse(data) + + found := false + for _, e := range r.Errors { + if e.Message == "step key must be in the form ingredient.method, got: nomethod" { + found = true + } + } + if !found { + t.Errorf("expected error about bad method key, got: %v", r.Errors) + } +} + +func TestStepIDs(t *testing.T) { + data := []byte(` +steps: + step one: + file.exists: + - name: /tmp/a + step two: + file.absent: + - name: /tmp/b +`) + r := Parse(data) + ids := r.StepIDs() + + if len(ids) != 2 { + t.Fatalf("expected 2 step IDs, got %d", len(ids)) + } + + idSet := make(map[string]bool) + for _, id := range ids { + idSet[id] = true + } + if !idSet["step one"] || !idSet["step two"] { + t.Errorf("missing expected step IDs: %v", ids) + } +} + +func TestParseRequisites(t *testing.T) { + data := []byte(` +steps: + first step: + file.exists: + - name: /tmp/a + second step: + file.exists: + - name: /tmp/b + - requisites: + - require: first step + - onchanges: + - first step +`) + r := Parse(data) + + if len(r.Errors) > 0 { + t.Fatalf("unexpected parse errors: %v", r.Errors) + } + + if len(r.Steps) != 2 { + t.Fatalf("expected 2 steps, got %d", len(r.Steps)) + } + + s := r.Steps[1] + if len(s.Requisites) != 2 { + t.Fatalf("expected 2 requisites, got %d", len(s.Requisites)) + } + if s.Requisites[0].Condition != "require" { + t.Errorf("requisite[0].Condition = %q, want %q", s.Requisites[0].Condition, "require") + } + if len(s.Requisites[0].StepIDs) != 1 || s.Requisites[0].StepIDs[0] != "first step" { + t.Errorf("requisite[0].StepIDs = %v, want [first step]", s.Requisites[0].StepIDs) + } +} + +func TestParseEmptyRecipe(t *testing.T) { + r := Parse([]byte("")) + if r == nil { + t.Fatal("expected non-nil recipe for empty input") + } +} + +func TestParseGoTemplate(t *testing.T) { + // Recipes can contain Go template syntax — the parser should not crash. + // Template directives may cause YAML errors, but the parser should handle gracefully. + data := []byte(` +steps: + install golang: + archive.extracted: + - name: /usr/local/go +`) + r := Parse(data) + if len(r.Steps) != 1 { + t.Fatalf("expected 1 step, got %d", len(r.Steps)) + } +} diff --git a/internal/schema/schema.go b/internal/schema/schema.go new file mode 100644 index 0000000..43edb2b --- /dev/null +++ b/internal/schema/schema.go @@ -0,0 +1,334 @@ +// Package schema defines the grlx recipe schema: ingredients, methods, +// properties, requisite types, and top-level recipe keys. +package schema + +// Ingredient describes a grlx ingredient and its available methods. +type Ingredient struct { + Name string + Description string + Methods []Method +} + +// Method describes a single method on an ingredient. +type Method struct { + Name string + Description string + Properties []Property +} + +// Property describes a configurable property for a method. +type Property struct { + Key string + Type string // "string", "bool", "[]string" + Required bool + Description string +} + +// RequisiteType describes a valid requisite condition. +type RequisiteType struct { + Name string + Description string +} + +// Registry holds the complete grlx schema for lookup. +type Registry struct { + Ingredients []Ingredient + RequisiteTypes []RequisiteType +} + +// TopLevelKeys are the valid top-level keys in a .grlx recipe file. +var TopLevelKeys = []string{"include", "steps"} + +// AllRequisiteTypes returns the known requisite conditions. +var AllRequisiteTypes = []RequisiteType{ + {Name: "require", Description: "Run this step only after the required step succeeds"}, + {Name: "require_any", Description: "Run this step if any of the listed steps succeed"}, + {Name: "onchanges", Description: "Run this step only if all listed steps made changes"}, + {Name: "onchanges_any", Description: "Run this step if any of the listed steps made changes"}, + {Name: "onfail", Description: "Run this step only if all listed steps failed"}, + {Name: "onfail_any", Description: "Run this step if any of the listed steps failed"}, +} + +// DefaultRegistry returns the built-in grlx ingredient registry, +// mirroring the ingredients defined in gogrlx/grlx. +func DefaultRegistry() *Registry { + return &Registry{ + Ingredients: allIngredients(), + RequisiteTypes: AllRequisiteTypes, + } +} + +// FindIngredient returns the ingredient with the given name, or nil. +func (r *Registry) FindIngredient(name string) *Ingredient { + for i := range r.Ingredients { + if r.Ingredients[i].Name == name { + return &r.Ingredients[i] + } + } + return nil +} + +// FindMethod returns the method on the given ingredient, or nil. +func (r *Registry) FindMethod(ingredientName, methodName string) *Method { + ing := r.FindIngredient(ingredientName) + if ing == nil { + return nil + } + for i := range ing.Methods { + if ing.Methods[i].Name == methodName { + return &ing.Methods[i] + } + } + return nil +} + +// AllDottedNames returns all "ingredient.method" strings. +func (r *Registry) AllDottedNames() []string { + var names []string + for _, ing := range r.Ingredients { + for _, m := range ing.Methods { + names = append(names, ing.Name+"."+m.Name) + } + } + return names +} + +func allIngredients() []Ingredient { + return []Ingredient{ + cmdIngredient(), + fileIngredient(), + groupIngredient(), + pkgIngredient(), + serviceIngredient(), + userIngredient(), + } +} + +func cmdIngredient() Ingredient { + return Ingredient{ + Name: "cmd", + Description: "Execute shell commands", + Methods: []Method{ + { + Name: "run", + Description: "Run a shell command", + Properties: []Property{ + {Key: "name", Type: "string", Required: true, Description: "The command to run"}, + {Key: "runas", Type: "string", Required: false, Description: "User to run the command as"}, + {Key: "cwd", Type: "string", Required: false, Description: "Working directory"}, + {Key: "env", Type: "[]string", Required: false, Description: "Environment variables"}, + {Key: "shell", Type: "string", Required: false, Description: "Shell to use"}, + {Key: "creates", Type: "string", Required: false, Description: "Only run if this file does not exist"}, + {Key: "unless", Type: "string", Required: false, Description: "Only run if this command fails"}, + {Key: "onlyif", Type: "string", Required: false, Description: "Only run if this command succeeds"}, + }, + }, + }, + } +} + +func fileIngredient() Ingredient { + return Ingredient{ + Name: "file", + Description: "Manage files and directories", + Methods: []Method{ + {Name: "absent", Description: "Ensure a file is absent", Properties: []Property{ + {Key: "name", Type: "string", Required: true, Description: "The name/path of the file to delete"}, + }}, + {Name: "append", Description: "Append text to a file", Properties: []Property{ + {Key: "name", Type: "string", Required: true, Description: "The name/path of the file to append to"}, + {Key: "makedirs", Type: "bool", Required: false, Description: "Create parent directories if they do not exist"}, + {Key: "source", Type: "string", Required: false, Description: "Append lines from a file sourced from this path/URL"}, + {Key: "source_hash", Type: "string", Required: false, Description: "Hash to verify the file specified by source"}, + {Key: "source_hashes", Type: "[]string", Required: false, Description: "Corresponding hashes for sources"}, + {Key: "sources", Type: "[]string", Required: false, Description: "Source, but in list format"}, + {Key: "template", Type: "bool", Required: false, Description: "Render the file as a template before appending"}, + {Key: "text", Type: "[]string", Required: false, Description: "The text to append to the file"}, + }}, + {Name: "cached", Description: "Cache a remote file locally", Properties: []Property{ + {Key: "name", Type: "string", Required: true, Description: "Local path for the cached file"}, + {Key: "source", Type: "string", Required: true, Description: "URL or path to cache from"}, + {Key: "hash", Type: "string", Required: false, Description: "Expected hash of the file"}, + {Key: "skip_verify", Type: "bool", Required: false, Description: "Skip hash verification"}, + }}, + {Name: "contains", Description: "Ensure a file contains specific content", Properties: []Property{ + {Key: "name", Type: "string", Required: true, Description: "Path of the file"}, + {Key: "source", Type: "string", Required: true, Description: "Source file to check against"}, + {Key: "source_hash", Type: "string", Required: false}, + {Key: "source_hashes", Type: "[]string", Required: false}, + {Key: "sources", Type: "[]string", Required: false}, + {Key: "template", Type: "bool", Required: false}, + {Key: "text", Type: "[]string", Required: false, Description: "Text that must be present"}, + }}, + {Name: "content", Description: "Manage the entire content of a file", Properties: []Property{ + {Key: "name", Type: "string", Required: true}, + {Key: "text", Type: "[]string", Required: false}, + {Key: "makedirs", Type: "bool", Required: false}, + {Key: "source", Type: "string", Required: false}, + {Key: "source_hash", Type: "string", Required: false}, + {Key: "template", Type: "bool", Required: false}, + {Key: "sources", Type: "[]string", Required: false}, + {Key: "source_hashes", Type: "[]string", Required: false}, + }}, + {Name: "directory", Description: "Ensure a directory exists with given permissions", Properties: []Property{ + {Key: "name", Type: "string", Required: true}, + {Key: "user", Type: "string", Required: false}, + {Key: "group", Type: "string", Required: false}, + {Key: "recurse", Type: "bool", Required: false}, + {Key: "dir_mode", Type: "string", Required: false}, + {Key: "file_mode", Type: "string", Required: false}, + {Key: "makedirs", Type: "bool", Required: false}, + }}, + {Name: "exists", Description: "Ensure a file exists (touch if needed)", Properties: []Property{ + {Key: "name", Type: "string", Required: true}, + }}, + {Name: "managed", Description: "Download and manage a file from a source", Properties: []Property{ + {Key: "name", Type: "string", Required: true}, + {Key: "source", Type: "string", Required: true}, + {Key: "source_hash", Type: "string", Required: false}, + {Key: "user", Type: "string", Required: false}, + {Key: "group", Type: "string", Required: false}, + {Key: "mode", Type: "string", Required: false}, + {Key: "template", Type: "bool", Required: false}, + {Key: "makedirs", Type: "bool", Required: false}, + {Key: "dir_mode", Type: "string", Required: false}, + {Key: "sources", Type: "[]string", Required: false}, + {Key: "source_hashes", Type: "[]string", Required: false}, + }}, + {Name: "missing", Description: "Verify a file does not exist (no-op check)", Properties: []Property{ + {Key: "name", Type: "string", Required: true}, + }}, + {Name: "prepend", Description: "Prepend text to a file", Properties: []Property{ + {Key: "name", Type: "string", Required: true}, + {Key: "text", Type: "[]string", Required: false}, + {Key: "makedirs", Type: "bool", Required: false}, + {Key: "source", Type: "string", Required: false}, + {Key: "source_hash", Type: "string", Required: false}, + {Key: "template", Type: "bool", Required: false}, + {Key: "sources", Type: "[]string", Required: false}, + {Key: "source_hashes", Type: "[]string", Required: false}, + }}, + {Name: "symlink", Description: "Manage a symbolic link", Properties: []Property{ + {Key: "name", Type: "string", Required: true, Description: "Path of the symlink"}, + {Key: "target", Type: "string", Required: true, Description: "Target the symlink points to"}, + {Key: "makedirs", Type: "bool", Required: false}, + {Key: "user", Type: "string", Required: false}, + {Key: "group", Type: "string", Required: false}, + {Key: "mode", Type: "string", Required: false}, + }}, + {Name: "touch", Description: "Touch a file (update mtime, create if missing)", Properties: []Property{ + {Key: "name", Type: "string", Required: true}, + }}, + }, + } +} + +func groupIngredient() Ingredient { + return Ingredient{ + Name: "group", + Description: "Manage system groups", + Methods: []Method{ + {Name: "absent", Description: "Ensure a group is absent", Properties: []Property{ + {Key: "name", Type: "string", Required: true}, + }}, + {Name: "exists", Description: "Check if a group exists", Properties: []Property{ + {Key: "name", Type: "string", Required: true}, + }}, + {Name: "present", Description: "Ensure a group is present", Properties: []Property{ + {Key: "name", Type: "string", Required: true}, + {Key: "gid", Type: "string", Required: false}, + }}, + }, + } +} + +func pkgIngredient() Ingredient { + return Ingredient{ + Name: "pkg", + Description: "Manage system packages", + Methods: []Method{ + {Name: "cleaned", Description: "Clean package cache"}, + {Name: "group_installed", Description: "Install a package group", Properties: []Property{ + {Key: "name", Type: "string", Required: true}, + }}, + {Name: "held", Description: "Hold a package at current version", Properties: []Property{ + {Key: "name", Type: "string", Required: true}, + }}, + {Name: "installed", Description: "Ensure a package is installed", Properties: []Property{ + {Key: "name", Type: "string", Required: true}, + {Key: "version", Type: "string", Required: false}, + }}, + {Name: "key_managed", Description: "Manage a package signing key", Properties: []Property{ + {Key: "name", Type: "string", Required: true}, + {Key: "source", Type: "string", Required: false}, + }}, + {Name: "latest", Description: "Ensure a package is at the latest version", Properties: []Property{ + {Key: "name", Type: "string", Required: true}, + }}, + {Name: "purged", Description: "Purge a package (remove with config)", Properties: []Property{ + {Key: "name", Type: "string", Required: true}, + }}, + {Name: "removed", Description: "Remove a package", Properties: []Property{ + {Key: "name", Type: "string", Required: true}, + }}, + {Name: "repo_managed", Description: "Manage a package repository", Properties: []Property{ + {Key: "name", Type: "string", Required: true}, + {Key: "source", Type: "string", Required: false}, + }}, + }, + } +} + +func serviceIngredient() Ingredient { + return Ingredient{ + Name: "service", + Description: "Manage system services", + Methods: []Method{ + {Name: "disabled", Description: "Ensure a service is disabled", Properties: []Property{ + {Key: "name", Type: "string", Required: true}, + }}, + {Name: "enabled", Description: "Ensure a service is enabled", Properties: []Property{ + {Key: "name", Type: "string", Required: true}, + }}, + {Name: "masked", Description: "Mask a service", Properties: []Property{ + {Key: "name", Type: "string", Required: true}, + }}, + {Name: "restarted", Description: "Restart a service", Properties: []Property{ + {Key: "name", Type: "string", Required: true}, + }}, + {Name: "running", Description: "Ensure a service is running", Properties: []Property{ + {Key: "name", Type: "string", Required: true}, + {Key: "enable", Type: "bool", Required: false, Description: "Also enable the service"}, + }}, + {Name: "stopped", Description: "Ensure a service is stopped", Properties: []Property{ + {Key: "name", Type: "string", Required: true}, + }}, + {Name: "unmasked", Description: "Unmask a service", Properties: []Property{ + {Key: "name", Type: "string", Required: true}, + }}, + }, + } +} + +func userIngredient() Ingredient { + return Ingredient{ + Name: "user", + Description: "Manage system users", + Methods: []Method{ + {Name: "absent", Description: "Ensure a user is absent", Properties: []Property{ + {Key: "name", Type: "string", Required: true}, + }}, + {Name: "exists", Description: "Check if a user exists", Properties: []Property{ + {Key: "name", Type: "string", Required: true}, + }}, + {Name: "present", Description: "Ensure a user is present", Properties: []Property{ + {Key: "name", Type: "string", Required: true}, + {Key: "uid", Type: "string", Required: false}, + {Key: "gid", Type: "string", Required: false}, + {Key: "home", Type: "string", Required: false}, + {Key: "shell", Type: "string", Required: false}, + {Key: "groups", Type: "[]string", Required: false}, + }}, + }, + } +} diff --git a/internal/schema/schema_test.go b/internal/schema/schema_test.go new file mode 100644 index 0000000..ba907ea --- /dev/null +++ b/internal/schema/schema_test.go @@ -0,0 +1,123 @@ +package schema + +import "testing" + +func TestDefaultRegistry(t *testing.T) { + r := DefaultRegistry() + + if len(r.Ingredients) == 0 { + t.Fatal("expected at least one ingredient") + } + + // Verify all expected ingredients are present + expected := []string{"cmd", "file", "group", "pkg", "service", "user"} + for _, name := range expected { + if r.FindIngredient(name) == nil { + t.Errorf("missing expected ingredient: %s", name) + } + } +} + +func TestFindIngredient(t *testing.T) { + r := DefaultRegistry() + + ing := r.FindIngredient("file") + if ing == nil { + t.Fatal("expected to find file ingredient") + } + if ing.Name != "file" { + t.Errorf("got name %q, want %q", ing.Name, "file") + } + + if r.FindIngredient("nonexistent") != nil { + t.Error("expected nil for nonexistent ingredient") + } +} + +func TestFindMethod(t *testing.T) { + r := DefaultRegistry() + + m := r.FindMethod("file", "managed") + if m == nil { + t.Fatal("expected to find file.managed") + } + if m.Name != "managed" { + t.Errorf("got method %q, want %q", m.Name, "managed") + } + + if r.FindMethod("file", "nonexistent") != nil { + t.Error("expected nil for nonexistent method") + } + if r.FindMethod("nonexistent", "managed") != nil { + t.Error("expected nil for nonexistent ingredient") + } +} + +func TestAllDottedNames(t *testing.T) { + r := DefaultRegistry() + names := r.AllDottedNames() + + if len(names) == 0 { + t.Fatal("expected at least one dotted name") + } + + // Check that some known names are present + nameSet := make(map[string]bool) + for _, n := range names { + nameSet[n] = true + } + + want := []string{"file.managed", "cmd.run", "pkg.installed", "service.running", "user.present", "group.present"} + for _, w := range want { + if !nameSet[w] { + t.Errorf("missing expected dotted name: %s", w) + } + } +} + +func TestFileMethods(t *testing.T) { + r := DefaultRegistry() + ing := r.FindIngredient("file") + if ing == nil { + t.Fatal("missing file ingredient") + } + + expectedMethods := []string{ + "absent", "append", "cached", "contains", "content", + "directory", "exists", "managed", "missing", "prepend", + "symlink", "touch", + } + methodSet := make(map[string]bool) + for _, m := range ing.Methods { + methodSet[m.Name] = true + } + for _, name := range expectedMethods { + if !methodSet[name] { + t.Errorf("file ingredient missing method: %s", name) + } + } +} + +func TestRequiredProperties(t *testing.T) { + r := DefaultRegistry() + m := r.FindMethod("file", "managed") + if m == nil { + t.Fatal("missing file.managed") + } + + // name and source should be required + propMap := make(map[string]Property) + for _, p := range m.Properties { + propMap[p.Key] = p + } + + if p, ok := propMap["name"]; !ok || !p.Required { + t.Error("file.managed: name should be required") + } + if p, ok := propMap["source"]; !ok || !p.Required { + t.Error("file.managed: source should be required") + } + if p, ok := propMap["user"]; !ok || p.Required { + t.Error("file.managed: user should be optional") + } +}