hugin/cli/internal/reconcile/json.go
2026-10-27 08:33:21 +01:00

326 lines
8.4 KiB
Go

package reconcile
import (
"bytes"
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/tailscale/hujson"
)
// jsonFile ist eine JSON- oder JSONC-Datei, die über JSON-Patch geändert
// wird. hujson erhält dabei Kommentare, Reihenfolge und die Form der übrigen
// Inhalte. Eingefügte Werte rückt formatieren im Stil der Datei ein.
type jsonFile struct {
value hujson.Value
// Einrückung der Datei, z. B. zwei Leerzeichen oder ein Tab
indent string
}
func readJson(raw []byte, next bool) (*jsonFile, error) {
if len(bytes.TrimSpace(raw)) == 0 {
raw = []byte("{}")
}
v, err := hujson.Parse(raw)
if err != nil {
return nil, err
}
return &jsonFile{value: v, indent: detectIndent(raw)}, nil
}
func detectIndent(raw []byte) string {
for _, row := range strings.Split(string(raw), "\n") {
rest := strings.TrimLeft(row, " \t")
if rest != "" && len(rest) < len(row) {
return row[:len(row)-len(rest)]
}
}
return " "
}
func (d *jsonFile) bytes() []byte {
b := d.value.Pack()
if !bytes.HasSuffix(b, []byte("\n")) {
b = append(b, '\n')
}
return b
}
// read liefert den Wert unter path als Go-Wert, ok=false wenn er fehlt.
func (d *jsonFile) read(path []string) (any, bool) {
clone := d.value.Clone()
clone.Standardize()
var value any
if err := json.Unmarshal(clone.Pack(), &value); err != nil {
return nil, false
}
for _, s := range path {
obj, ok := value.(map[string]any)
if !ok {
return nil, false
}
if value, ok = obj[s]; !ok {
return nil, false
}
}
return value, true
}
// empty sagt, ob die Datei nur noch ein leeres Objekt enthält.
// OpenCodeSchema legt hugin in eine neue opencode.json.
const OpenCodeSchema = "https://opencode.ai/config.json"
// empty: ein leeres Objekt; ein $schema allein zählt nicht als Inhalt.
func (d *jsonFile) empty() bool {
value, ok := d.read(nil)
obj, isObject := value.(map[string]any)
delete(obj, "$schema")
return ok && isObject && len(obj) == 0
}
func (d *jsonFile) set(path []string, value json.RawMessage) error {
if existing, ok := d.read(path); ok && canonical(existing) == canonicalRaw(value) {
return nil
}
// Fehlende Zwischenobjekte nicht einzeln anlegen, sondern den Wert ab der
// ersten fehlenden Ebene verschachtelt einfügen: ergibt saubere Einrückung.
for i := 1; i < len(path); i++ {
existing, ok := d.read(path[:i])
if !ok {
for j := len(path) - 1; j >= i; j-- {
key, _ := json.Marshal(path[j])
value = json.RawMessage("{" + string(key) + ":" + string(value) + "}")
}
path = path[:i]
break
}
if _, isObject := existing.(map[string]any); !isObject {
return fmt.Errorf("%s ist kein Objekt", pointer(path[:i]))
}
}
if err := d.patch("add", path, value); err != nil {
return err
}
return d.format(path[:len(path)-1], path[len(path)-1])
}
func (d *jsonFile) remove(path []string) error {
if _, ok := d.read(path); !ok {
return nil
}
if err := d.patch("remove", path, nil); err != nil {
return err
}
return d.removeEmptyParents(path)
}
// removeEmptyParents entfernt Objekte über pfad, die durch das Entfernen leer
// geworden sind, z. B. "mcpServers": {} nach dem letzten Server.
func (d *jsonFile) removeEmptyParents(path []string) error {
for i := len(path) - 1; i > 0; i-- {
value, ok := d.read(path[:i])
obj, isObject := value.(map[string]any)
if !ok || !isObject || len(obj) > 0 {
return nil
}
if err := d.patch("remove", path[:i], nil); err != nil {
return err
}
}
return nil
}
// addToList stellt sicher, dass wert in der Liste unter pfad steht.
func (d *jsonFile) addToList(path []string, value json.RawMessage) error {
existing, ok := d.read(path)
if !ok {
return d.set(path, json.RawMessage("["+string(value)+"]"))
}
items, isList := existing.([]any)
if !isList {
return fmt.Errorf("%s ist keine Liste", pointer(path))
}
if index(items, value) >= 0 {
return nil
}
if err := d.patch("add", append(append([]string{}, path...), "-"), value); err != nil {
return err
}
return d.format(path, "")
}
func (d *jsonFile) removeFromList(path []string, value json.RawMessage) error {
existing, _ := d.read(path)
items, _ := existing.([]any)
i := index(items, value)
if i < 0 {
return nil
}
if len(items) == 1 {
// Eine leere Liste nützt niemandem: den Schlüssel ganz entfernen
return d.remove(path)
}
return d.patch("remove", append(append([]string{}, path...), fmt.Sprint(i)), nil)
}
func index(items []any, value json.RawMessage) int {
for i, entry := range items {
if canonical(entry) == canonicalRaw(value) {
return i
}
}
return -1
}
func (d *jsonFile) patch(op string, path []string, value json.RawMessage) error {
step := map[string]any{"op": op, "path": pointer(path)}
if value != nil {
step["value"] = value
}
raw, err := json.Marshal([]any{step})
if err != nil {
return err
}
return d.value.Patch(raw)
}
// pointer baut einen JSON-Pointer (RFC 6901) aus den Schlüsseln.
func pointer(path []string) string {
var b strings.Builder
for _, s := range path {
b.WriteString("/")
b.WriteString(strings.NewReplacer("~", "~0", "/", "~1").Replace(s))
}
return b.String()
}
func canonical(value any) string {
b, _ := json.Marshal(value)
return string(b)
}
func canonicalRaw(raw json.RawMessage) string {
var value any
if err := json.Unmarshal(raw, &value); err != nil {
return string(raw)
}
return canonical(value)
}
// format rückt das eben gesetzte Element ein: den Member name eines
// Objekts bzw. (name leer) das letzte Element einer Liste unter eltern.
// Steht das Elternelement in einer Zeile, bleibt es einzeilig.
func (d *jsonFile) format(parents []string, name string) error {
node := d.value.Find(pointer(parents))
if node == nil {
return nil
}
depth := len(parents) + 1
inside := "\n" + strings.Repeat(d.indent, depth)
outside := "\n" + strings.Repeat(d.indent, depth-1)
switch c := node.Value.(type) {
case *hujson.Object:
member := withName(c, name)
if member == nil {
return nil
}
var before []hujson.Extra // Abstand vor den übrigen Membern
for i := range c.Members {
if &c.Members[i] != member {
before = append(before, c.Members[i].Name.BeforeExtra)
}
}
multiline := multiline(before)
switch {
case multiline:
member.Name.BeforeExtra = []byte(inside)
case member == &c.Members[0]:
member.Name.BeforeExtra = nil
default:
member.Name.BeforeExtra = []byte(" ")
}
if err := d.reindent(&member.Value, multiline, depth); err != nil {
return err
}
member.Value.BeforeExtra = []byte(" ")
member.Value.AfterExtra = nil
if multiline && !comment(c.AfterExtra) {
c.AfterExtra = []byte(outside)
}
case *hujson.Array:
if len(c.Elements) == 0 {
return nil
}
var before []hujson.Extra
for _, e := range c.Elements[:len(c.Elements)-1] {
before = append(before, e.BeforeExtra)
}
target := &c.Elements[len(c.Elements)-1]
multiline := multiline(before)
if err := d.reindent(target, multiline, depth); err != nil {
return err
}
switch {
case multiline:
target.BeforeExtra = []byte(inside)
if !comment(c.AfterExtra) {
c.AfterExtra = []byte(outside)
}
case len(c.Elements) > 1:
target.BeforeExtra = []byte(" ")
default:
target.BeforeExtra = nil
}
target.AfterExtra = nil
}
d.value.UpdateOffsets()
return nil
}
// reindent ersetzt den Wert durch eine eingerückte (oder kompakte) Fassung.
func (d *jsonFile) reindent(v *hujson.Value, multiline bool, depth int) error {
clone := v.Clone()
clone.Standardize()
var buffer bytes.Buffer
if multiline {
if err := json.Indent(&buffer, bytes.TrimSpace(clone.Pack()), strings.Repeat(d.indent, depth), d.indent); err != nil {
return err
}
} else if err := json.Compact(&buffer, clone.Pack()); err != nil {
return err
}
next, err := hujson.Parse(buffer.Bytes())
if err != nil {
return err
}
v.Value = next.Value
return nil
}
func withName(obj *hujson.Object, name string) *hujson.ObjectMember {
for i := range obj.Members {
if n, _ := strconv.Unquote(string(obj.Members[i].Name.Value.(hujson.Literal))); n == name {
return &obj.Members[i]
}
}
return nil
}
// mehrzeilig: Die übrigen Elemente stehen auf eigenen Zeilen, oder es gibt
// keine übrigen (dann wird ein neues Objekt bzw. eine neue Liste mehrzeilig).
func multiline(before []hujson.Extra) bool {
if len(before) == 0 {
return true
}
for _, e := range before {
if bytes.Contains(e, []byte("\n")) {
return true
}
}
return false
}
func comment(e hujson.Extra) bool { return bytes.Contains(e, []byte("/")) }