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
This commit is contained in:
2026-03-06 08:45:12 +00:00
commit d5a9585c58
17 changed files with 2259 additions and 0 deletions

28
.github/workflows/ci.yml vendored Normal file
View File

@@ -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 ./...

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
grlx-lsp
dist/

12
LICENSE Normal file
View File

@@ -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.

91
README.md Normal file
View File

@@ -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

119
flake.lock generated Normal file
View File

@@ -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
}

97
flake.nix Normal file
View File

@@ -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
];
};
};
};
}

20
go.mod Normal file
View File

@@ -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
)

83
go.sum Normal file
View File

@@ -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=

277
internal/lsp/completion.go Normal file
View File

@@ -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(), &params); 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
}

183
internal/lsp/diagnostics.go Normal file
View File

@@ -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)},
}
}

145
internal/lsp/handler.go Normal file
View File

@@ -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(), &params); 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(), &params); 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(), &params); 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]
}

View File

@@ -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)
}
}
}

128
internal/lsp/hover.go Normal file
View File

@@ -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(), &params); 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 == '-'
}

241
internal/recipe/recipe.go Normal file
View File

@@ -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
}

View File

@@ -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))
}
}

334
internal/schema/schema.go Normal file
View File

@@ -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},
}},
},
}
}

View File

@@ -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")
}
}