Files
grlx-lsp/internal/lsp/hover.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

129 lines
3.0 KiB
Go

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 == '-'
}