commit d5a9585c58ce4c56ac23f3c3c9c23d35a4b7f4a7 Author: Tai Groot Date: Fri Mar 6 08:45:12 2026 +0000 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 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") + } +}