mirror of
https://github.com/gogrlx/grlx-lsp.git
synced 2026-04-02 03:18:47 -07:00
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:
28
.github/workflows/ci.yml
vendored
Normal file
28
.github/workflows/ci.yml
vendored
Normal 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
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
grlx-lsp
|
||||
dist/
|
||||
12
LICENSE
Normal file
12
LICENSE
Normal 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
91
README.md
Normal 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
119
flake.lock
generated
Normal 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
97
flake.nix
Normal 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
20
go.mod
Normal 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
83
go.sum
Normal 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
277
internal/lsp/completion.go
Normal 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(), ¶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
|
||||
}
|
||||
183
internal/lsp/diagnostics.go
Normal file
183
internal/lsp/diagnostics.go
Normal 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
145
internal/lsp/handler.go
Normal 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(), ¶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]
|
||||
}
|
||||
189
internal/lsp/handler_test.go
Normal file
189
internal/lsp/handler_test.go
Normal 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
128
internal/lsp/hover.go
Normal 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(), ¶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 == '-'
|
||||
}
|
||||
241
internal/recipe/recipe.go
Normal file
241
internal/recipe/recipe.go
Normal 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
|
||||
}
|
||||
187
internal/recipe/recipe_test.go
Normal file
187
internal/recipe/recipe_test.go
Normal 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
334
internal/schema/schema.go
Normal 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},
|
||||
}},
|
||||
},
|
||||
}
|
||||
}
|
||||
123
internal/schema/schema_test.go
Normal file
123
internal/schema/schema_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user