326 lines
8.4 KiB
Go
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("/")) }
|