feat: grlx LSP server in Go

LSP server for grlx recipe files (.grlx) providing:

- Completion for ingredients, methods, properties, requisite types,
  and step ID references
- Diagnostics for unknown ingredients/methods, missing required
  properties, unknown properties, and invalid requisite types
- Hover documentation for all ingredients and methods with property
  tables
- Full schema for all 6 grlx ingredients (cmd, file, group, pkg,
  service, user) with accurate properties from the grlx source
This commit is contained in:
2026-03-06 08:45:12 +00:00
commit d5a9585c58
17 changed files with 2259 additions and 0 deletions

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

@@ -0,0 +1,334 @@
// Package schema defines the grlx recipe schema: ingredients, methods,
// properties, requisite types, and top-level recipe keys.
package schema
// Ingredient describes a grlx ingredient and its available methods.
type Ingredient struct {
Name string
Description string
Methods []Method
}
// Method describes a single method on an ingredient.
type Method struct {
Name string
Description string
Properties []Property
}
// Property describes a configurable property for a method.
type Property struct {
Key string
Type string // "string", "bool", "[]string"
Required bool
Description string
}
// RequisiteType describes a valid requisite condition.
type RequisiteType struct {
Name string
Description string
}
// Registry holds the complete grlx schema for lookup.
type Registry struct {
Ingredients []Ingredient
RequisiteTypes []RequisiteType
}
// TopLevelKeys are the valid top-level keys in a .grlx recipe file.
var TopLevelKeys = []string{"include", "steps"}
// AllRequisiteTypes returns the known requisite conditions.
var AllRequisiteTypes = []RequisiteType{
{Name: "require", Description: "Run this step only after the required step succeeds"},
{Name: "require_any", Description: "Run this step if any of the listed steps succeed"},
{Name: "onchanges", Description: "Run this step only if all listed steps made changes"},
{Name: "onchanges_any", Description: "Run this step if any of the listed steps made changes"},
{Name: "onfail", Description: "Run this step only if all listed steps failed"},
{Name: "onfail_any", Description: "Run this step if any of the listed steps failed"},
}
// DefaultRegistry returns the built-in grlx ingredient registry,
// mirroring the ingredients defined in gogrlx/grlx.
func DefaultRegistry() *Registry {
return &Registry{
Ingredients: allIngredients(),
RequisiteTypes: AllRequisiteTypes,
}
}
// FindIngredient returns the ingredient with the given name, or nil.
func (r *Registry) FindIngredient(name string) *Ingredient {
for i := range r.Ingredients {
if r.Ingredients[i].Name == name {
return &r.Ingredients[i]
}
}
return nil
}
// FindMethod returns the method on the given ingredient, or nil.
func (r *Registry) FindMethod(ingredientName, methodName string) *Method {
ing := r.FindIngredient(ingredientName)
if ing == nil {
return nil
}
for i := range ing.Methods {
if ing.Methods[i].Name == methodName {
return &ing.Methods[i]
}
}
return nil
}
// AllDottedNames returns all "ingredient.method" strings.
func (r *Registry) AllDottedNames() []string {
var names []string
for _, ing := range r.Ingredients {
for _, m := range ing.Methods {
names = append(names, ing.Name+"."+m.Name)
}
}
return names
}
func allIngredients() []Ingredient {
return []Ingredient{
cmdIngredient(),
fileIngredient(),
groupIngredient(),
pkgIngredient(),
serviceIngredient(),
userIngredient(),
}
}
func cmdIngredient() Ingredient {
return Ingredient{
Name: "cmd",
Description: "Execute shell commands",
Methods: []Method{
{
Name: "run",
Description: "Run a shell command",
Properties: []Property{
{Key: "name", Type: "string", Required: true, Description: "The command to run"},
{Key: "runas", Type: "string", Required: false, Description: "User to run the command as"},
{Key: "cwd", Type: "string", Required: false, Description: "Working directory"},
{Key: "env", Type: "[]string", Required: false, Description: "Environment variables"},
{Key: "shell", Type: "string", Required: false, Description: "Shell to use"},
{Key: "creates", Type: "string", Required: false, Description: "Only run if this file does not exist"},
{Key: "unless", Type: "string", Required: false, Description: "Only run if this command fails"},
{Key: "onlyif", Type: "string", Required: false, Description: "Only run if this command succeeds"},
},
},
},
}
}
func fileIngredient() Ingredient {
return Ingredient{
Name: "file",
Description: "Manage files and directories",
Methods: []Method{
{Name: "absent", Description: "Ensure a file is absent", Properties: []Property{
{Key: "name", Type: "string", Required: true, Description: "The name/path of the file to delete"},
}},
{Name: "append", Description: "Append text to a file", Properties: []Property{
{Key: "name", Type: "string", Required: true, Description: "The name/path of the file to append to"},
{Key: "makedirs", Type: "bool", Required: false, Description: "Create parent directories if they do not exist"},
{Key: "source", Type: "string", Required: false, Description: "Append lines from a file sourced from this path/URL"},
{Key: "source_hash", Type: "string", Required: false, Description: "Hash to verify the file specified by source"},
{Key: "source_hashes", Type: "[]string", Required: false, Description: "Corresponding hashes for sources"},
{Key: "sources", Type: "[]string", Required: false, Description: "Source, but in list format"},
{Key: "template", Type: "bool", Required: false, Description: "Render the file as a template before appending"},
{Key: "text", Type: "[]string", Required: false, Description: "The text to append to the file"},
}},
{Name: "cached", Description: "Cache a remote file locally", Properties: []Property{
{Key: "name", Type: "string", Required: true, Description: "Local path for the cached file"},
{Key: "source", Type: "string", Required: true, Description: "URL or path to cache from"},
{Key: "hash", Type: "string", Required: false, Description: "Expected hash of the file"},
{Key: "skip_verify", Type: "bool", Required: false, Description: "Skip hash verification"},
}},
{Name: "contains", Description: "Ensure a file contains specific content", Properties: []Property{
{Key: "name", Type: "string", Required: true, Description: "Path of the file"},
{Key: "source", Type: "string", Required: true, Description: "Source file to check against"},
{Key: "source_hash", Type: "string", Required: false},
{Key: "source_hashes", Type: "[]string", Required: false},
{Key: "sources", Type: "[]string", Required: false},
{Key: "template", Type: "bool", Required: false},
{Key: "text", Type: "[]string", Required: false, Description: "Text that must be present"},
}},
{Name: "content", Description: "Manage the entire content of a file", Properties: []Property{
{Key: "name", Type: "string", Required: true},
{Key: "text", Type: "[]string", Required: false},
{Key: "makedirs", Type: "bool", Required: false},
{Key: "source", Type: "string", Required: false},
{Key: "source_hash", Type: "string", Required: false},
{Key: "template", Type: "bool", Required: false},
{Key: "sources", Type: "[]string", Required: false},
{Key: "source_hashes", Type: "[]string", Required: false},
}},
{Name: "directory", Description: "Ensure a directory exists with given permissions", Properties: []Property{
{Key: "name", Type: "string", Required: true},
{Key: "user", Type: "string", Required: false},
{Key: "group", Type: "string", Required: false},
{Key: "recurse", Type: "bool", Required: false},
{Key: "dir_mode", Type: "string", Required: false},
{Key: "file_mode", Type: "string", Required: false},
{Key: "makedirs", Type: "bool", Required: false},
}},
{Name: "exists", Description: "Ensure a file exists (touch if needed)", Properties: []Property{
{Key: "name", Type: "string", Required: true},
}},
{Name: "managed", Description: "Download and manage a file from a source", Properties: []Property{
{Key: "name", Type: "string", Required: true},
{Key: "source", Type: "string", Required: true},
{Key: "source_hash", Type: "string", Required: false},
{Key: "user", Type: "string", Required: false},
{Key: "group", Type: "string", Required: false},
{Key: "mode", Type: "string", Required: false},
{Key: "template", Type: "bool", Required: false},
{Key: "makedirs", Type: "bool", Required: false},
{Key: "dir_mode", Type: "string", Required: false},
{Key: "sources", Type: "[]string", Required: false},
{Key: "source_hashes", Type: "[]string", Required: false},
}},
{Name: "missing", Description: "Verify a file does not exist (no-op check)", Properties: []Property{
{Key: "name", Type: "string", Required: true},
}},
{Name: "prepend", Description: "Prepend text to a file", Properties: []Property{
{Key: "name", Type: "string", Required: true},
{Key: "text", Type: "[]string", Required: false},
{Key: "makedirs", Type: "bool", Required: false},
{Key: "source", Type: "string", Required: false},
{Key: "source_hash", Type: "string", Required: false},
{Key: "template", Type: "bool", Required: false},
{Key: "sources", Type: "[]string", Required: false},
{Key: "source_hashes", Type: "[]string", Required: false},
}},
{Name: "symlink", Description: "Manage a symbolic link", Properties: []Property{
{Key: "name", Type: "string", Required: true, Description: "Path of the symlink"},
{Key: "target", Type: "string", Required: true, Description: "Target the symlink points to"},
{Key: "makedirs", Type: "bool", Required: false},
{Key: "user", Type: "string", Required: false},
{Key: "group", Type: "string", Required: false},
{Key: "mode", Type: "string", Required: false},
}},
{Name: "touch", Description: "Touch a file (update mtime, create if missing)", Properties: []Property{
{Key: "name", Type: "string", Required: true},
}},
},
}
}
func groupIngredient() Ingredient {
return Ingredient{
Name: "group",
Description: "Manage system groups",
Methods: []Method{
{Name: "absent", Description: "Ensure a group is absent", Properties: []Property{
{Key: "name", Type: "string", Required: true},
}},
{Name: "exists", Description: "Check if a group exists", Properties: []Property{
{Key: "name", Type: "string", Required: true},
}},
{Name: "present", Description: "Ensure a group is present", Properties: []Property{
{Key: "name", Type: "string", Required: true},
{Key: "gid", Type: "string", Required: false},
}},
},
}
}
func pkgIngredient() Ingredient {
return Ingredient{
Name: "pkg",
Description: "Manage system packages",
Methods: []Method{
{Name: "cleaned", Description: "Clean package cache"},
{Name: "group_installed", Description: "Install a package group", Properties: []Property{
{Key: "name", Type: "string", Required: true},
}},
{Name: "held", Description: "Hold a package at current version", Properties: []Property{
{Key: "name", Type: "string", Required: true},
}},
{Name: "installed", Description: "Ensure a package is installed", Properties: []Property{
{Key: "name", Type: "string", Required: true},
{Key: "version", Type: "string", Required: false},
}},
{Name: "key_managed", Description: "Manage a package signing key", Properties: []Property{
{Key: "name", Type: "string", Required: true},
{Key: "source", Type: "string", Required: false},
}},
{Name: "latest", Description: "Ensure a package is at the latest version", Properties: []Property{
{Key: "name", Type: "string", Required: true},
}},
{Name: "purged", Description: "Purge a package (remove with config)", Properties: []Property{
{Key: "name", Type: "string", Required: true},
}},
{Name: "removed", Description: "Remove a package", Properties: []Property{
{Key: "name", Type: "string", Required: true},
}},
{Name: "repo_managed", Description: "Manage a package repository", Properties: []Property{
{Key: "name", Type: "string", Required: true},
{Key: "source", Type: "string", Required: false},
}},
},
}
}
func serviceIngredient() Ingredient {
return Ingredient{
Name: "service",
Description: "Manage system services",
Methods: []Method{
{Name: "disabled", Description: "Ensure a service is disabled", Properties: []Property{
{Key: "name", Type: "string", Required: true},
}},
{Name: "enabled", Description: "Ensure a service is enabled", Properties: []Property{
{Key: "name", Type: "string", Required: true},
}},
{Name: "masked", Description: "Mask a service", Properties: []Property{
{Key: "name", Type: "string", Required: true},
}},
{Name: "restarted", Description: "Restart a service", Properties: []Property{
{Key: "name", Type: "string", Required: true},
}},
{Name: "running", Description: "Ensure a service is running", Properties: []Property{
{Key: "name", Type: "string", Required: true},
{Key: "enable", Type: "bool", Required: false, Description: "Also enable the service"},
}},
{Name: "stopped", Description: "Ensure a service is stopped", Properties: []Property{
{Key: "name", Type: "string", Required: true},
}},
{Name: "unmasked", Description: "Unmask a service", Properties: []Property{
{Key: "name", Type: "string", Required: true},
}},
},
}
}
func userIngredient() Ingredient {
return Ingredient{
Name: "user",
Description: "Manage system users",
Methods: []Method{
{Name: "absent", Description: "Ensure a user is absent", Properties: []Property{
{Key: "name", Type: "string", Required: true},
}},
{Name: "exists", Description: "Check if a user exists", Properties: []Property{
{Key: "name", Type: "string", Required: true},
}},
{Name: "present", Description: "Ensure a user is present", Properties: []Property{
{Key: "name", Type: "string", Required: true},
{Key: "uid", Type: "string", Required: false},
{Key: "gid", Type: "string", Required: false},
{Key: "home", Type: "string", Required: false},
{Key: "shell", Type: "string", Required: false},
{Key: "groups", Type: "[]string", Required: false},
}},
},
}
}

View File

@@ -0,0 +1,123 @@
package schema
import "testing"
func TestDefaultRegistry(t *testing.T) {
r := DefaultRegistry()
if len(r.Ingredients) == 0 {
t.Fatal("expected at least one ingredient")
}
// Verify all expected ingredients are present
expected := []string{"cmd", "file", "group", "pkg", "service", "user"}
for _, name := range expected {
if r.FindIngredient(name) == nil {
t.Errorf("missing expected ingredient: %s", name)
}
}
}
func TestFindIngredient(t *testing.T) {
r := DefaultRegistry()
ing := r.FindIngredient("file")
if ing == nil {
t.Fatal("expected to find file ingredient")
}
if ing.Name != "file" {
t.Errorf("got name %q, want %q", ing.Name, "file")
}
if r.FindIngredient("nonexistent") != nil {
t.Error("expected nil for nonexistent ingredient")
}
}
func TestFindMethod(t *testing.T) {
r := DefaultRegistry()
m := r.FindMethod("file", "managed")
if m == nil {
t.Fatal("expected to find file.managed")
}
if m.Name != "managed" {
t.Errorf("got method %q, want %q", m.Name, "managed")
}
if r.FindMethod("file", "nonexistent") != nil {
t.Error("expected nil for nonexistent method")
}
if r.FindMethod("nonexistent", "managed") != nil {
t.Error("expected nil for nonexistent ingredient")
}
}
func TestAllDottedNames(t *testing.T) {
r := DefaultRegistry()
names := r.AllDottedNames()
if len(names) == 0 {
t.Fatal("expected at least one dotted name")
}
// Check that some known names are present
nameSet := make(map[string]bool)
for _, n := range names {
nameSet[n] = true
}
want := []string{"file.managed", "cmd.run", "pkg.installed", "service.running", "user.present", "group.present"}
for _, w := range want {
if !nameSet[w] {
t.Errorf("missing expected dotted name: %s", w)
}
}
}
func TestFileMethods(t *testing.T) {
r := DefaultRegistry()
ing := r.FindIngredient("file")
if ing == nil {
t.Fatal("missing file ingredient")
}
expectedMethods := []string{
"absent", "append", "cached", "contains", "content",
"directory", "exists", "managed", "missing", "prepend",
"symlink", "touch",
}
methodSet := make(map[string]bool)
for _, m := range ing.Methods {
methodSet[m.Name] = true
}
for _, name := range expectedMethods {
if !methodSet[name] {
t.Errorf("file ingredient missing method: %s", name)
}
}
}
func TestRequiredProperties(t *testing.T) {
r := DefaultRegistry()
m := r.FindMethod("file", "managed")
if m == nil {
t.Fatal("missing file.managed")
}
// name and source should be required
propMap := make(map[string]Property)
for _, p := range m.Properties {
propMap[p.Key] = p
}
if p, ok := propMap["name"]; !ok || !p.Required {
t.Error("file.managed: name should be required")
}
if p, ok := propMap["source"]; !ok || !p.Required {
t.Error("file.managed: source should be required")
}
if p, ok := propMap["user"]; !ok || p.Required {
t.Error("file.managed: user should be optional")
}
}