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