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