Files
grlx-lsp/internal/recipe/recipe.go
Tai Groot d5a9585c58 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
2026-03-06 09:14:10 +00:00

242 lines
5.4 KiB
Go

// 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
}