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

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

@@ -0,0 +1,277 @@
package lsp
import (
"context"
"encoding/json"
"strings"
"go.lsp.dev/jsonrpc2"
"go.lsp.dev/protocol"
"github.com/gogrlx/grlx-lsp/internal/schema"
)
func (h *Handler) handleCompletion(_ context.Context, reply jsonrpc2.Replier, req jsonrpc2.Request) error {
ctx := context.Background()
var params protocol.CompletionParams
if err := json.Unmarshal(req.Params(), &params); err != nil {
return reply(ctx, nil, err)
}
doc := h.getDocument(string(params.TextDocument.URI))
if doc == nil {
return reply(ctx, &protocol.CompletionList{}, nil)
}
line := lineAt(doc.content, int(params.Position.Line))
col := int(params.Position.Character)
if col > len(line) {
col = len(line)
}
prefix := strings.TrimSpace(line[:col])
var items []protocol.CompletionItem
switch {
case isTopLevel(doc.content, int(params.Position.Line)):
items = h.completeTopLevel(prefix)
case isInRequisites(line):
items = h.completeRequisiteTypes(prefix)
case isInRequisiteValue(doc.content, int(params.Position.Line)):
items = h.completeStepIDs(doc)
case isPropertyPosition(line):
items = h.completeProperties(doc, int(params.Position.Line))
default:
items = h.completeIngredientMethod(prefix)
}
return reply(ctx, &protocol.CompletionList{
IsIncomplete: false,
Items: items,
}, nil)
}
func (h *Handler) completeTopLevel(_ string) []protocol.CompletionItem {
var items []protocol.CompletionItem
for _, key := range schema.TopLevelKeys {
items = append(items, protocol.CompletionItem{
Label: key,
Kind: protocol.CompletionItemKindKeyword,
})
}
return items
}
func (h *Handler) completeIngredientMethod(prefix string) []protocol.CompletionItem {
var items []protocol.CompletionItem
// If prefix contains a dot, complete methods for that ingredient
if dotIdx := strings.Index(prefix, "."); dotIdx >= 0 {
ingName := prefix[:dotIdx]
ing := h.registry.FindIngredient(ingName)
if ing != nil {
for _, m := range ing.Methods {
items = append(items, protocol.CompletionItem{
Label: ingName + "." + m.Name,
Kind: protocol.CompletionItemKindFunction,
Detail: m.Description,
Documentation: buildMethodDoc(ing, &m),
})
}
}
return items
}
// Otherwise, complete all ingredient.method combos
for _, name := range h.registry.AllDottedNames() {
parts := strings.SplitN(name, ".", 2)
ing := h.registry.FindIngredient(parts[0])
m := h.registry.FindMethod(parts[0], parts[1])
detail := ""
if m != nil {
detail = m.Description
}
doc := ""
if ing != nil && m != nil {
doc = buildMethodDoc(ing, m)
}
items = append(items, protocol.CompletionItem{
Label: name,
Kind: protocol.CompletionItemKindFunction,
Detail: detail,
Documentation: doc,
})
}
return items
}
func (h *Handler) completeProperties(doc *document, line int) []protocol.CompletionItem {
var items []protocol.CompletionItem
// Find which step this line belongs to
step := h.findStepForLine(doc, line)
if step == nil {
return items
}
m := h.registry.FindMethod(step.Ingredient, step.Method)
if m == nil {
return items
}
// Collect already-used properties
used := make(map[string]bool)
for _, p := range step.Properties {
used[p.Key] = true
}
for _, prop := range m.Properties {
if used[prop.Key] {
continue
}
detail := prop.Type
if prop.Required {
detail += " (required)"
}
items = append(items, protocol.CompletionItem{
Label: "- " + prop.Key + ": ",
InsertText: "- " + prop.Key + ": ",
Kind: protocol.CompletionItemKindProperty,
Detail: detail,
})
}
// Also offer requisites
if !used["requisites"] {
items = append(items, protocol.CompletionItem{
Label: "- requisites:",
Kind: protocol.CompletionItemKindKeyword,
Detail: "Step dependencies",
})
}
return items
}
func (h *Handler) completeRequisiteTypes(_ string) []protocol.CompletionItem {
var items []protocol.CompletionItem
for _, rt := range schema.AllRequisiteTypes {
items = append(items, protocol.CompletionItem{
Label: "- " + rt.Name + ": ",
Kind: protocol.CompletionItemKindEnum,
Detail: rt.Description,
})
}
return items
}
func (h *Handler) completeStepIDs(doc *document) []protocol.CompletionItem {
var items []protocol.CompletionItem
if doc.recipe == nil {
return items
}
for _, id := range doc.recipe.StepIDs() {
items = append(items, protocol.CompletionItem{
Label: id,
Kind: protocol.CompletionItemKindReference,
})
}
return items
}
func buildMethodDoc(ing *schema.Ingredient, m *schema.Method) string {
var sb strings.Builder
sb.WriteString(ing.Name + "." + m.Name)
if m.Description != "" {
sb.WriteString(" — " + m.Description)
}
if len(m.Properties) > 0 {
sb.WriteString("\n\nProperties:\n")
for _, p := range m.Properties {
marker := " "
if p.Required {
marker = "* "
}
sb.WriteString(marker + p.Key + " (" + p.Type + ")")
if p.Description != "" {
sb.WriteString(" — " + p.Description)
}
sb.WriteString("\n")
}
}
return sb.String()
}
// Heuristics for context detection
func isTopLevel(content string, line int) bool {
lines := strings.Split(content, "\n")
if line < 0 || line >= len(lines) {
return false
}
l := lines[line]
// Top-level if no leading whitespace or empty
return len(l) == 0 || (len(strings.TrimLeft(l, " \t")) == len(l))
}
func isInRequisites(line string) bool {
trimmed := strings.TrimSpace(line)
// Inside a requisites block: indented under "- requisites:"
return strings.HasPrefix(trimmed, "- require") ||
strings.HasPrefix(trimmed, "- onchanges") ||
strings.HasPrefix(trimmed, "- onfail")
}
func isInRequisiteValue(content string, line int) bool {
lines := strings.Split(content, "\n")
// Look backwards for a requisite condition key
for i := line; i >= 0 && i >= line-5; i-- {
trimmed := strings.TrimSpace(lines[i])
if strings.HasPrefix(trimmed, "- require:") ||
strings.HasPrefix(trimmed, "- require_any:") ||
strings.HasPrefix(trimmed, "- onchanges:") ||
strings.HasPrefix(trimmed, "- onchanges_any:") ||
strings.HasPrefix(trimmed, "- onfail:") ||
strings.HasPrefix(trimmed, "- onfail_any:") {
return true
}
}
return false
}
func isPropertyPosition(line string) bool {
trimmed := strings.TrimSpace(line)
// Property lines start with "- " and are indented
indent := len(line) - len(strings.TrimLeft(line, " "))
return indent >= 4 && (strings.HasPrefix(trimmed, "- ") || trimmed == "-" || trimmed == "")
}
func (h *Handler) findStepForLine(doc *document, line int) *struct {
Ingredient string
Method string
Properties []struct{ Key string }
} {
if doc.recipe == nil {
return nil
}
// Find the step whose method node is on or before this line
for i := len(doc.recipe.Steps) - 1; i >= 0; i-- {
s := &doc.recipe.Steps[i]
if s.MethodNode != nil && s.MethodNode.Line-1 <= line {
result := &struct {
Ingredient string
Method string
Properties []struct{ Key string }
}{
Ingredient: s.Ingredient,
Method: s.Method,
}
for _, p := range s.Properties {
result.Properties = append(result.Properties, struct{ Key string }{Key: p.Key})
}
return result
}
}
return nil
}

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

@@ -0,0 +1,183 @@
package lsp
import (
"context"
"go.lsp.dev/protocol"
"gopkg.in/yaml.v3"
"github.com/gogrlx/grlx-lsp/internal/recipe"
"github.com/gogrlx/grlx-lsp/internal/schema"
)
func (h *Handler) publishDiagnostics(ctx context.Context, uri string) {
doc := h.getDocument(uri)
if doc == nil || h.conn == nil {
return
}
diags := h.diagnose(doc)
_ = h.conn.Notify(ctx, "textDocument/publishDiagnostics", protocol.PublishDiagnosticsParams{
URI: protocol.DocumentURI(uri),
Diagnostics: diags,
})
}
func (h *Handler) diagnose(doc *document) []protocol.Diagnostic {
var diags []protocol.Diagnostic
if doc.recipe == nil {
return diags
}
// Report parse errors
for _, e := range doc.recipe.Errors {
diags = append(diags, protocol.Diagnostic{
Range: pointRange(e.Line-1, e.Col-1),
Severity: protocol.DiagnosticSeverityError,
Source: "grlx-lsp",
Message: e.Message,
})
}
stepIDs := make(map[string]bool)
for _, s := range doc.recipe.Steps {
stepIDs[s.ID] = true
}
for _, s := range doc.recipe.Steps {
if s.Ingredient == "" {
continue
}
ing := h.registry.FindIngredient(s.Ingredient)
if ing == nil {
diags = append(diags, protocol.Diagnostic{
Range: yamlNodeRange(s.MethodNode),
Severity: protocol.DiagnosticSeverityError,
Source: "grlx-lsp",
Message: "unknown ingredient: " + s.Ingredient,
})
continue
}
m := h.registry.FindMethod(s.Ingredient, s.Method)
if m == nil {
diags = append(diags, protocol.Diagnostic{
Range: yamlNodeRange(s.MethodNode),
Severity: protocol.DiagnosticSeverityError,
Source: "grlx-lsp",
Message: "unknown method: " + s.Ingredient + "." + s.Method,
})
continue
}
diags = append(diags, checkRequired(s, m)...)
diags = append(diags, checkUnknown(s, m)...)
// Validate requisite types and references
for _, req := range s.Requisites {
if !isValidRequisiteType(req.Condition) {
diags = append(diags, protocol.Diagnostic{
Range: yamlNodeRange(req.Node),
Severity: protocol.DiagnosticSeverityError,
Source: "grlx-lsp",
Message: "unknown requisite type: " + req.Condition,
})
}
for _, ref := range req.StepIDs {
if !stepIDs[ref] {
diags = append(diags, protocol.Diagnostic{
Range: yamlNodeRange(req.Node),
Severity: protocol.DiagnosticSeverityWarning,
Source: "grlx-lsp",
Message: "reference to unknown step: " + ref + " (may be defined in an included recipe)",
})
}
}
}
}
return diags
}
func checkRequired(s recipe.Step, m *schema.Method) []protocol.Diagnostic {
var diags []protocol.Diagnostic
propKeys := make(map[string]bool)
for _, p := range s.Properties {
propKeys[p.Key] = true
}
for _, prop := range m.Properties {
if prop.Required && !propKeys[prop.Key] {
diags = append(diags, protocol.Diagnostic{
Range: yamlNodeRange(s.MethodNode),
Severity: protocol.DiagnosticSeverityWarning,
Source: "grlx-lsp",
Message: "missing required property: " + prop.Key,
})
}
}
return diags
}
func checkUnknown(s recipe.Step, m *schema.Method) []protocol.Diagnostic {
var diags []protocol.Diagnostic
validProps := make(map[string]bool)
for _, prop := range m.Properties {
validProps[prop.Key] = true
}
validProps["requisites"] = true
for _, p := range s.Properties {
if !validProps[p.Key] {
diags = append(diags, protocol.Diagnostic{
Range: yamlNodeRange(p.KeyNode),
Severity: protocol.DiagnosticSeverityWarning,
Source: "grlx-lsp",
Message: "unknown property: " + p.Key + " for " + s.Ingredient + "." + s.Method,
})
}
}
return diags
}
func isValidRequisiteType(name string) bool {
for _, rt := range schema.AllRequisiteTypes {
if rt.Name == name {
return true
}
}
return false
}
func pointRange(line, col int) protocol.Range {
if line < 0 {
line = 0
}
if col < 0 {
col = 0
}
return protocol.Range{
Start: protocol.Position{Line: uint32(line), Character: uint32(col)},
End: protocol.Position{Line: uint32(line), Character: uint32(col + 1)},
}
}
func yamlNodeRange(node *yaml.Node) protocol.Range {
if node == nil {
return pointRange(0, 0)
}
line := node.Line - 1
col := node.Column - 1
endCol := col + len(node.Value)
if line < 0 {
line = 0
}
if col < 0 {
col = 0
}
return protocol.Range{
Start: protocol.Position{Line: uint32(line), Character: uint32(col)},
End: protocol.Position{Line: uint32(line), Character: uint32(endCol)},
}
}

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

@@ -0,0 +1,145 @@
// Package lsp implements the Language Server Protocol handler for grlx recipes.
package lsp
import (
"context"
"encoding/json"
"strings"
"sync"
"go.lsp.dev/jsonrpc2"
"go.lsp.dev/protocol"
"github.com/gogrlx/grlx-lsp/internal/recipe"
"github.com/gogrlx/grlx-lsp/internal/schema"
)
// Handler implements the LSP server.
type Handler struct {
conn jsonrpc2.Conn
registry *schema.Registry
mu sync.RWMutex
docs map[string]*document // URI -> document
}
type document struct {
content string
recipe *recipe.Recipe
}
// NewHandler creates a new LSP handler.
func NewHandler(registry *schema.Registry) *Handler {
return &Handler{
registry: registry,
docs: make(map[string]*document),
}
}
// SetConn sets the jsonrpc2 connection for sending notifications.
func (h *Handler) SetConn(conn jsonrpc2.Conn) {
h.conn = conn
}
// Handle dispatches LSP requests.
func (h *Handler) Handle(ctx context.Context, reply jsonrpc2.Replier, req jsonrpc2.Request) error {
switch req.Method() {
case "initialize":
return h.handleInitialize(ctx, reply, req)
case "initialized":
return reply(ctx, nil, nil)
case "shutdown":
return reply(ctx, nil, nil)
case "exit":
return reply(ctx, nil, nil)
case "textDocument/didOpen":
return h.handleDidOpen(ctx, reply, req)
case "textDocument/didChange":
return h.handleDidChange(ctx, reply, req)
case "textDocument/didClose":
return h.handleDidClose(ctx, reply, req)
case "textDocument/didSave":
return reply(ctx, nil, nil)
case "textDocument/completion":
return h.handleCompletion(ctx, reply, req)
case "textDocument/hover":
return h.handleHover(ctx, reply, req)
case "textDocument/diagnostic":
return reply(ctx, nil, nil)
default:
return reply(ctx, nil, jsonrpc2.NewError(jsonrpc2.MethodNotFound, "method not supported: "+req.Method()))
}
}
func (h *Handler) handleInitialize(_ context.Context, reply jsonrpc2.Replier, _ jsonrpc2.Request) error {
return reply(context.Background(), protocol.InitializeResult{
Capabilities: protocol.ServerCapabilities{
TextDocumentSync: protocol.TextDocumentSyncOptions{
OpenClose: true,
Change: protocol.TextDocumentSyncKindFull,
},
CompletionProvider: &protocol.CompletionOptions{
TriggerCharacters: []string{".", ":", "-", " "},
},
HoverProvider: true,
},
ServerInfo: &protocol.ServerInfo{
Name: "grlx-lsp",
Version: "0.1.0",
},
}, nil)
}
func (h *Handler) handleDidOpen(ctx context.Context, reply jsonrpc2.Replier, req jsonrpc2.Request) error {
var params protocol.DidOpenTextDocumentParams
if err := json.Unmarshal(req.Params(), &params); err != nil {
return reply(ctx, nil, err)
}
h.updateDocument(string(params.TextDocument.URI), params.TextDocument.Text)
h.publishDiagnostics(ctx, string(params.TextDocument.URI))
return reply(ctx, nil, nil)
}
func (h *Handler) handleDidChange(ctx context.Context, reply jsonrpc2.Replier, req jsonrpc2.Request) error {
var params protocol.DidChangeTextDocumentParams
if err := json.Unmarshal(req.Params(), &params); err != nil {
return reply(ctx, nil, err)
}
if len(params.ContentChanges) > 0 {
h.updateDocument(string(params.TextDocument.URI), params.ContentChanges[len(params.ContentChanges)-1].Text)
h.publishDiagnostics(ctx, string(params.TextDocument.URI))
}
return reply(ctx, nil, nil)
}
func (h *Handler) handleDidClose(_ context.Context, reply jsonrpc2.Replier, req jsonrpc2.Request) error {
var params protocol.DidCloseTextDocumentParams
if err := json.Unmarshal(req.Params(), &params); err != nil {
return reply(context.Background(), nil, err)
}
h.mu.Lock()
delete(h.docs, string(params.TextDocument.URI))
h.mu.Unlock()
return reply(context.Background(), nil, nil)
}
func (h *Handler) updateDocument(uri, content string) {
r := recipe.Parse([]byte(content))
h.mu.Lock()
h.docs[uri] = &document{content: content, recipe: r}
h.mu.Unlock()
}
func (h *Handler) getDocument(uri string) *document {
h.mu.RLock()
defer h.mu.RUnlock()
return h.docs[uri]
}
// lineAt returns the text of the given line (0-indexed).
func lineAt(content string, line int) string {
lines := strings.Split(content, "\n")
if line < 0 || line >= len(lines) {
return ""
}
return lines[line]
}

View File

@@ -0,0 +1,189 @@
package lsp
import (
"testing"
"github.com/gogrlx/grlx-lsp/internal/recipe"
"github.com/gogrlx/grlx-lsp/internal/schema"
)
func TestDiagnoseUnknownIngredient(t *testing.T) {
h := NewHandler(schema.DefaultRegistry())
doc := &document{
content: `steps:
bad step:
bogus.method:
- name: foo`,
recipe: recipe.Parse([]byte(`steps:
bad step:
bogus.method:
- name: foo`)),
}
diags := h.diagnose(doc)
found := false
for _, d := range diags {
if d.Message == "unknown ingredient: bogus" {
found = true
}
}
if !found {
t.Errorf("expected unknown ingredient diagnostic, got: %v", diags)
}
}
func TestDiagnoseUnknownMethod(t *testing.T) {
h := NewHandler(schema.DefaultRegistry())
src := `steps:
bad step:
file.nonexistent:
- name: foo`
doc := &document{
content: src,
recipe: recipe.Parse([]byte(src)),
}
diags := h.diagnose(doc)
found := false
for _, d := range diags {
if d.Message == "unknown method: file.nonexistent" {
found = true
}
}
if !found {
t.Errorf("expected unknown method diagnostic, got: %v", diags)
}
}
func TestDiagnoseMissingRequired(t *testing.T) {
h := NewHandler(schema.DefaultRegistry())
// file.managed requires both name and source
src := `steps:
manage file:
file.managed:
- user: root`
doc := &document{
content: src,
recipe: recipe.Parse([]byte(src)),
}
diags := h.diagnose(doc)
foundName := false
foundSource := false
for _, d := range diags {
if d.Message == "missing required property: name" {
foundName = true
}
if d.Message == "missing required property: source" {
foundSource = true
}
}
if !foundName {
t.Error("expected diagnostic for missing required property: name")
}
if !foundSource {
t.Error("expected diagnostic for missing required property: source")
}
}
func TestDiagnoseUnknownProperty(t *testing.T) {
h := NewHandler(schema.DefaultRegistry())
src := `steps:
my step:
file.absent:
- name: /tmp/foo
- bogusprop: bar`
doc := &document{
content: src,
recipe: recipe.Parse([]byte(src)),
}
diags := h.diagnose(doc)
found := false
for _, d := range diags {
if d.Message == "unknown property: bogusprop for file.absent" {
found = true
}
}
if !found {
t.Errorf("expected unknown property diagnostic, got: %v", diags)
}
}
func TestDiagnoseValidRecipe(t *testing.T) {
h := NewHandler(schema.DefaultRegistry())
src := `steps:
install nginx:
pkg.installed:
- name: nginx`
doc := &document{
content: src,
recipe: recipe.Parse([]byte(src)),
}
diags := h.diagnose(doc)
if len(diags) != 0 {
t.Errorf("expected no diagnostics for valid recipe, got: %v", diags)
}
}
func TestDiagnoseUnknownRequisiteType(t *testing.T) {
h := NewHandler(schema.DefaultRegistry())
src := `steps:
first:
file.exists:
- name: /tmp/a
second:
file.exists:
- name: /tmp/b
- requisites:
- bogus_req: first`
doc := &document{
content: src,
recipe: recipe.Parse([]byte(src)),
}
diags := h.diagnose(doc)
found := false
for _, d := range diags {
if d.Message == "unknown requisite type: bogus_req" {
found = true
}
}
if !found {
t.Errorf("expected unknown requisite type diagnostic, got: %v", diags)
}
}
func TestLineAt(t *testing.T) {
content := "line0\nline1\nline2"
if got := lineAt(content, 0); got != "line0" {
t.Errorf("lineAt(0) = %q, want %q", got, "line0")
}
if got := lineAt(content, 2); got != "line2" {
t.Errorf("lineAt(2) = %q, want %q", got, "line2")
}
if got := lineAt(content, 99); got != "" {
t.Errorf("lineAt(99) = %q, want empty", got)
}
}
func TestWordAtPosition(t *testing.T) {
tests := []struct {
line string
col int
want string
}{
{" file.managed:", 8, "file.managed"},
{" - name: foo", 6, "name"},
{" - require: step one", 10, "require"},
{"", 0, ""},
{" pkg.installed:", 5, "pkg.installed"},
}
for _, tt := range tests {
got := wordAtPosition(tt.line, tt.col)
if got != tt.want {
t.Errorf("wordAtPosition(%q, %d) = %q, want %q", tt.line, tt.col, got, tt.want)
}
}
}

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

@@ -0,0 +1,128 @@
package lsp
import (
"context"
"encoding/json"
"strings"
"go.lsp.dev/jsonrpc2"
"go.lsp.dev/protocol"
"github.com/gogrlx/grlx-lsp/internal/schema"
)
func (h *Handler) handleHover(_ context.Context, reply jsonrpc2.Replier, req jsonrpc2.Request) error {
ctx := context.Background()
var params protocol.HoverParams
if err := json.Unmarshal(req.Params(), &params); err != nil {
return reply(ctx, nil, err)
}
doc := h.getDocument(string(params.TextDocument.URI))
if doc == nil {
return reply(ctx, nil, nil)
}
line := lineAt(doc.content, int(params.Position.Line))
word := wordAtPosition(line, int(params.Position.Character))
if word == "" {
return reply(ctx, nil, nil)
}
// Check if it's an ingredient.method reference
if strings.Contains(word, ".") {
parts := strings.SplitN(word, ".", 2)
if len(parts) == 2 {
m := h.registry.FindMethod(parts[0], parts[1])
ing := h.registry.FindIngredient(parts[0])
if m != nil && ing != nil {
return reply(ctx, &protocol.Hover{
Contents: protocol.MarkupContent{
Kind: protocol.Markdown,
Value: buildMethodMarkdown(ing.Name, m),
},
}, nil)
}
}
}
// Check if it's just an ingredient name
ing := h.registry.FindIngredient(word)
if ing != nil {
var methods []string
for _, m := range ing.Methods {
methods = append(methods, ing.Name+"."+m.Name)
}
return reply(ctx, &protocol.Hover{
Contents: protocol.MarkupContent{
Kind: protocol.Markdown,
Value: "**" + ing.Name + "** — " + ing.Description + "\n\nMethods: `" + strings.Join(methods, "`, `") + "`",
},
}, nil)
}
// Check if it's a requisite type
for _, rt := range h.registry.RequisiteTypes {
if rt.Name == word {
return reply(ctx, &protocol.Hover{
Contents: protocol.MarkupContent{
Kind: protocol.Markdown,
Value: "**" + rt.Name + "** — " + rt.Description,
},
}, nil)
}
}
return reply(ctx, nil, nil)
}
func buildMethodMarkdown(ingredient string, m *schema.Method) string {
var sb strings.Builder
sb.WriteString("### " + ingredient + "." + m.Name + "\n\n")
if m.Description != "" {
sb.WriteString(m.Description + "\n\n")
}
if len(m.Properties) > 0 {
sb.WriteString("| Property | Type | Required | Description |\n")
sb.WriteString("|----------|------|----------|-------------|\n")
for _, p := range m.Properties {
req := ""
if p.Required {
req = "yes"
}
desc := p.Description
if desc == "" {
desc = "—"
}
sb.WriteString("| `" + p.Key + "` | " + p.Type + " | " + req + " | " + desc + " |\n")
}
}
return sb.String()
}
func wordAtPosition(line string, col int) string {
if col > len(line) {
col = len(line)
}
start := col
for start > 0 && isWordChar(line[start-1]) {
start--
}
end := col
for end < len(line) && isWordChar(line[end]) {
end++
}
return line[start:end]
}
func isWordChar(b byte) bool {
return (b >= 'a' && b <= 'z') ||
(b >= 'A' && b <= 'Z') ||
(b >= '0' && b <= '9') ||
b == '_' || b == '.' || b == '-'
}

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

@@ -0,0 +1,241 @@
// Package recipe parses .grlx recipe files and provides structured access
// to their contents for diagnostics, completion, and hover.
package recipe
import (
"strings"
"gopkg.in/yaml.v3"
)
// Recipe represents a parsed .grlx file.
type Recipe struct {
Root *yaml.Node
Includes []Include
Steps []Step
Errors []ParseError
}
// Include represents a single include directive.
type Include struct {
Value string
Node *yaml.Node
}
// Step represents a single step in the recipe.
type Step struct {
ID string
IDNode *yaml.Node
Ingredient string
Method string
MethodNode *yaml.Node
Properties []PropertyEntry
Requisites []RequisiteEntry
}
// PropertyEntry is a key-value pair in a step's property list.
type PropertyEntry struct {
Key string
Value interface{}
KeyNode *yaml.Node
ValueNode *yaml.Node
}
// RequisiteEntry is a parsed requisite condition.
type RequisiteEntry struct {
Condition string
StepIDs []string
Node *yaml.Node
}
// ParseError represents a recipe parse error with location.
type ParseError struct {
Message string
Line int
Col int
}
// Parse parses raw YAML bytes into a Recipe.
func Parse(data []byte) *Recipe {
r := &Recipe{}
var doc yaml.Node
if err := yaml.Unmarshal(data, &doc); err != nil {
r.Errors = append(r.Errors, ParseError{Message: "invalid YAML: " + err.Error(), Line: 0, Col: 0})
return r
}
if doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 {
return r
}
r.Root = doc.Content[0]
if r.Root.Kind != yaml.MappingNode {
r.Errors = append(r.Errors, ParseError{Message: "recipe must be a YAML mapping", Line: r.Root.Line, Col: r.Root.Column})
return r
}
for i := 0; i+1 < len(r.Root.Content); i += 2 {
keyNode := r.Root.Content[i]
valNode := r.Root.Content[i+1]
switch keyNode.Value {
case "include":
r.parseIncludes(valNode)
case "steps":
r.parseSteps(valNode)
default:
r.Errors = append(r.Errors, ParseError{
Message: "unknown top-level key: " + keyNode.Value,
Line: keyNode.Line,
Col: keyNode.Column,
})
}
}
return r
}
func (r *Recipe) parseIncludes(node *yaml.Node) {
if node.Kind != yaml.SequenceNode {
r.Errors = append(r.Errors, ParseError{
Message: "include must be a list",
Line: node.Line,
Col: node.Column,
})
return
}
for _, item := range node.Content {
if item.Kind == yaml.ScalarNode {
r.Includes = append(r.Includes, Include{Value: item.Value, Node: item})
}
}
}
func (r *Recipe) parseSteps(node *yaml.Node) {
if node.Kind != yaml.MappingNode {
r.Errors = append(r.Errors, ParseError{
Message: "steps must be a mapping",
Line: node.Line,
Col: node.Column,
})
return
}
for i := 0; i+1 < len(node.Content); i += 2 {
stepIDNode := node.Content[i]
stepBodyNode := node.Content[i+1]
r.parseStep(stepIDNode, stepBodyNode)
}
}
func (r *Recipe) parseStep(idNode, bodyNode *yaml.Node) {
if bodyNode.Kind != yaml.MappingNode {
r.Errors = append(r.Errors, ParseError{
Message: "step body must be a mapping",
Line: bodyNode.Line,
Col: bodyNode.Column,
})
return
}
if len(bodyNode.Content) < 2 {
r.Errors = append(r.Errors, ParseError{
Message: "step must have exactly one ingredient.method key",
Line: bodyNode.Line,
Col: bodyNode.Column,
})
return
}
methodKeyNode := bodyNode.Content[0]
methodValNode := bodyNode.Content[1]
parts := strings.SplitN(methodKeyNode.Value, ".", 2)
ingredient := ""
method := ""
if len(parts) == 2 {
ingredient = parts[0]
method = parts[1]
} else {
r.Errors = append(r.Errors, ParseError{
Message: "step key must be in the form ingredient.method, got: " + methodKeyNode.Value,
Line: methodKeyNode.Line,
Col: methodKeyNode.Column,
})
}
step := Step{
ID: idNode.Value,
IDNode: idNode,
Ingredient: ingredient,
Method: method,
MethodNode: methodKeyNode,
}
// The value should be a sequence of mappings (property list)
if methodValNode.Kind == yaml.SequenceNode {
for _, item := range methodValNode.Content {
if item.Kind == yaml.MappingNode {
for j := 0; j+1 < len(item.Content); j += 2 {
k := item.Content[j]
v := item.Content[j+1]
if k.Value == "requisites" {
step.Requisites = parseRequisites(v)
} else {
step.Properties = append(step.Properties, PropertyEntry{
Key: k.Value,
KeyNode: k,
ValueNode: v,
})
}
}
}
}
}
r.Steps = append(r.Steps, step)
}
func parseRequisites(node *yaml.Node) []RequisiteEntry {
var reqs []RequisiteEntry
if node.Kind != yaml.SequenceNode {
return reqs
}
for _, item := range node.Content {
if item.Kind != yaml.MappingNode {
continue
}
for i := 0; i+1 < len(item.Content); i += 2 {
condition := item.Content[i].Value
valNode := item.Content[i+1]
var stepIDs []string
switch valNode.Kind {
case yaml.ScalarNode:
stepIDs = append(stepIDs, valNode.Value)
case yaml.SequenceNode:
for _, s := range valNode.Content {
if s.Kind == yaml.ScalarNode {
stepIDs = append(stepIDs, s.Value)
}
}
}
reqs = append(reqs, RequisiteEntry{
Condition: condition,
StepIDs: stepIDs,
Node: item.Content[i],
})
}
}
return reqs
}
// StepIDs returns all step IDs defined in this recipe.
func (r *Recipe) StepIDs() []string {
var ids []string
for _, s := range r.Steps {
ids = append(ids, s.ID)
}
return ids
}

View File

@@ -0,0 +1,187 @@
package recipe
import "testing"
func TestParseSimpleRecipe(t *testing.T) {
data := []byte(`
include:
- apache
- .dev
steps:
install nginx:
pkg.installed:
- name: nginx
start nginx:
service.running:
- name: nginx
- requisites:
- require: install nginx
`)
r := Parse(data)
if len(r.Errors) > 0 {
t.Fatalf("unexpected parse errors: %v", r.Errors)
}
if len(r.Includes) != 2 {
t.Fatalf("expected 2 includes, got %d", len(r.Includes))
}
if r.Includes[0].Value != "apache" {
t.Errorf("include[0] = %q, want %q", r.Includes[0].Value, "apache")
}
if r.Includes[1].Value != ".dev" {
t.Errorf("include[1] = %q, want %q", r.Includes[1].Value, ".dev")
}
if len(r.Steps) != 2 {
t.Fatalf("expected 2 steps, got %d", len(r.Steps))
}
s := r.Steps[0]
if s.ID != "install nginx" {
t.Errorf("step[0].ID = %q, want %q", s.ID, "install nginx")
}
if s.Ingredient != "pkg" {
t.Errorf("step[0].Ingredient = %q, want %q", s.Ingredient, "pkg")
}
if s.Method != "installed" {
t.Errorf("step[0].Method = %q, want %q", s.Method, "installed")
}
if len(s.Properties) != 1 || s.Properties[0].Key != "name" {
t.Errorf("step[0] expected name property, got %v", s.Properties)
}
}
func TestParseInvalidYAML(t *testing.T) {
data := []byte(`{{{invalid`)
r := Parse(data)
if len(r.Errors) == 0 {
t.Error("expected parse errors for invalid YAML")
}
}
func TestParseUnknownTopLevel(t *testing.T) {
data := []byte(`
include:
- foo
bogus_key: bar
steps: {}
`)
r := Parse(data)
found := false
for _, e := range r.Errors {
if e.Message == "unknown top-level key: bogus_key" {
found = true
}
}
if !found {
t.Error("expected error about unknown top-level key")
}
}
func TestParseBadMethodKey(t *testing.T) {
data := []byte(`
steps:
bad step:
nomethod:
- name: foo
`)
r := Parse(data)
found := false
for _, e := range r.Errors {
if e.Message == "step key must be in the form ingredient.method, got: nomethod" {
found = true
}
}
if !found {
t.Errorf("expected error about bad method key, got: %v", r.Errors)
}
}
func TestStepIDs(t *testing.T) {
data := []byte(`
steps:
step one:
file.exists:
- name: /tmp/a
step two:
file.absent:
- name: /tmp/b
`)
r := Parse(data)
ids := r.StepIDs()
if len(ids) != 2 {
t.Fatalf("expected 2 step IDs, got %d", len(ids))
}
idSet := make(map[string]bool)
for _, id := range ids {
idSet[id] = true
}
if !idSet["step one"] || !idSet["step two"] {
t.Errorf("missing expected step IDs: %v", ids)
}
}
func TestParseRequisites(t *testing.T) {
data := []byte(`
steps:
first step:
file.exists:
- name: /tmp/a
second step:
file.exists:
- name: /tmp/b
- requisites:
- require: first step
- onchanges:
- first step
`)
r := Parse(data)
if len(r.Errors) > 0 {
t.Fatalf("unexpected parse errors: %v", r.Errors)
}
if len(r.Steps) != 2 {
t.Fatalf("expected 2 steps, got %d", len(r.Steps))
}
s := r.Steps[1]
if len(s.Requisites) != 2 {
t.Fatalf("expected 2 requisites, got %d", len(s.Requisites))
}
if s.Requisites[0].Condition != "require" {
t.Errorf("requisite[0].Condition = %q, want %q", s.Requisites[0].Condition, "require")
}
if len(s.Requisites[0].StepIDs) != 1 || s.Requisites[0].StepIDs[0] != "first step" {
t.Errorf("requisite[0].StepIDs = %v, want [first step]", s.Requisites[0].StepIDs)
}
}
func TestParseEmptyRecipe(t *testing.T) {
r := Parse([]byte(""))
if r == nil {
t.Fatal("expected non-nil recipe for empty input")
}
}
func TestParseGoTemplate(t *testing.T) {
// Recipes can contain Go template syntax — the parser should not crash.
// Template directives may cause YAML errors, but the parser should handle gracefully.
data := []byte(`
steps:
install golang:
archive.extracted:
- name: /usr/local/go
`)
r := Parse(data)
if len(r.Steps) != 1 {
t.Fatalf("expected 1 step, got %d", len(r.Steps))
}
}

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

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

View File

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