package plugins import ( "bytes" "encoding/json" "fmt" "strconv" "strings" "github.com/tailscale/hujson" ) type jsoncEditor struct { value hujson.Value eol string indent string label string } func parseJSONC(source []byte, label string, allowTrailingCommas bool) (hujson.Value, error) { value, err := hujson.Parse(source) if err != nil { return hujson.Value{}, fmt.Errorf("parse %s: %w", label, err) } if !allowTrailingCommas { if err := rejectJSONCTrailingCommas(&value, label, "$"); err != nil { return hujson.Value{}, err } } if err := rejectDuplicateJSONCKeys(&value, label, "$"); err != nil { return hujson.Value{}, err } return value, nil } func rejectJSONCTrailingCommas(value *hujson.Value, label, location string) error { switch current := value.Value.(type) { case *hujson.Object: if len(current.Members) > 0 && current.Members[len(current.Members)-1].Value.AfterExtra != nil { return fmt.Errorf("%s contains a trailing comma at %s", label, location) } for index := range current.Members { member := ¤t.Members[index] name := member.Name.Value.(hujson.Literal).String() if err := rejectJSONCTrailingCommas(&member.Value, label, location+"/"+name); err != nil { return err } } case *hujson.Array: if len(current.Elements) > 0 && current.Elements[len(current.Elements)-1].AfterExtra != nil { return fmt.Errorf("%s contains a trailing comma at %s", label, location) } for index := range current.Elements { if err := rejectJSONCTrailingCommas( ¤t.Elements[index], label, location+"/"+strconv.Itoa(index), ); err != nil { return err } } } return nil } func rejectDuplicateJSONCKeys(value *hujson.Value, label, location string) error { switch current := value.Value.(type) { case *hujson.Object: seen := make(map[string]bool, len(current.Members)) for index := range current.Members { member := ¤t.Members[index] name := member.Name.Value.(hujson.Literal).String() if seen[name] { return fmt.Errorf("%s contains duplicate key %q at %s", label, name, location) } seen[name] = true if err := rejectDuplicateJSONCKeys(&member.Value, label, location+"/"+name); err != nil { return err } } case *hujson.Array: for index := range current.Elements { if err := rejectDuplicateJSONCKeys( ¤t.Elements[index], label, location+"/"+strconv.Itoa(index), ); err != nil { return err } } } return nil } func decodeStrictJSONObject(source []byte, label string) (map[string]any, error) { if !json.Valid(source) { var value any if err := json.Unmarshal(source, &value); err != nil { return nil, fmt.Errorf("parse %s: %w", label, err) } } return decodeJSONCObject(source, label, false) } func decodeJSONCObject(source []byte, label string, allowTrailingCommas bool) (map[string]any, error) { value, err := parseJSONC(source, label, allowTrailingCommas) if err != nil { return nil, err } if _, ok := value.Value.(*hujson.Object); !ok { return nil, fmt.Errorf("%s must contain a JSON object", label) } standard := value.Clone() standard.Standardize() var object map[string]any if err := json.Unmarshal(standard.Pack(), &object); err != nil { return nil, fmt.Errorf("decode %s: %w", label, err) } return object, nil } func newJSONCEditor(source []byte, label string, allowTrailingCommas bool) (*jsoncEditor, error) { value, err := parseJSONC(source, label, allowTrailingCommas) if err != nil { return nil, err } return &jsoncEditor{ value: value, eol: detectJSONCEOL(source), indent: detectJSONCIndent(source), label: label, }, nil } func detectJSONCEOL(source []byte) string { if bytes.Contains(source, []byte("\r\n")) { return "\r\n" } return "\n" } func detectJSONCIndent(source []byte) string { normalized := strings.ReplaceAll(string(source), "\r\n", "\n") for _, line := range strings.Split(normalized, "\n") { trimmed := strings.TrimLeft(line, "\t ") if !strings.HasPrefix(trimmed, `"`) || trimmed == line { continue } return line[:len(line)-len(trimmed)] } return " " } func (editor *jsoncEditor) pack() []byte { return editor.value.Pack() } func (editor *jsoncEditor) remove(path []any) error { pointer := jsonPointer(path) operation := []map[string]any{{"op": "remove", "path": pointer}} patch, err := json.Marshal(operation) if err != nil { return fmt.Errorf("encode %s removal: %w", editor.label, err) } if err := editor.value.Patch(patch); err != nil { return fmt.Errorf("apply %s removal at %s: %w", editor.label, pointer, err) } return nil } func (editor *jsoncEditor) addProperty(objectPath []any, name string, value any) error { container, err := findJSONCValue(&editor.value, objectPath, editor.label) if err != nil { return err } object, ok := container.Value.(*hujson.Object) if !ok { return fmt.Errorf("%s path %s must be an object", editor.label, jsonPointer(objectPath)) } for _, member := range object.Members { if member.Name.Value.(hujson.Literal).String() == name { return fmt.Errorf("%s property %q already exists", editor.label, name) } } propertyDepth := len(objectPath) + 1 formatted, err := editor.formattedValue(value, propertyDepth) if err != nil { return err } trailingComma := len(object.Members) > 0 && object.Members[len(object.Members)-1].Value.AfterExtra != nil leading := editor.newLeadingExtra(object.AfterExtra, propertyDepth) object.AfterExtra = []byte(editor.eol + strings.Repeat(editor.indent, len(objectPath))) member := hujson.ObjectMember{ Name: hujson.Value{BeforeExtra: leading, Value: hujson.String(name)}, Value: hujson.Value{BeforeExtra: []byte(" "), Value: formatted.Value}, } if trailingComma { member.Value.AfterExtra = []byte{} } object.Members = append(object.Members, member) return nil } func (editor *jsoncEditor) appendArray(arrayPath []any, value any) error { container, err := findJSONCValue(&editor.value, arrayPath, editor.label) if err != nil { return err } array, ok := container.Value.(*hujson.Array) if !ok { return fmt.Errorf("%s path %s must be an array", editor.label, jsonPointer(arrayPath)) } arrayDepth := len(arrayPath) formatted, err := editor.formattedValue(value, arrayDepth+1) if err != nil { return err } trailingComma := len(array.Elements) > 0 && array.Elements[len(array.Elements)-1].AfterExtra != nil formatted.BeforeExtra = editor.newLeadingExtra(array.AfterExtra, arrayDepth+1) if trailingComma { formatted.AfterExtra = []byte{} } else { formatted.AfterExtra = nil } array.AfterExtra = []byte(editor.eol + strings.Repeat(editor.indent, arrayDepth)) array.Elements = append(array.Elements, formatted) return nil } func (editor *jsoncEditor) formattedValue(value any, depth int) (hujson.Value, error) { prefix := strings.Repeat(editor.indent, depth) encoded, err := json.MarshalIndent(value, prefix, editor.indent) if err != nil { return hujson.Value{}, fmt.Errorf("encode value for %s: %w", editor.label, err) } if editor.eol != "\n" { encoded = bytes.ReplaceAll(encoded, []byte("\n"), []byte(editor.eol)) } parsed, err := hujson.Parse(encoded) if err != nil { return hujson.Value{}, fmt.Errorf("parse generated value for %s: %w", editor.label, err) } return parsed, nil } func (editor *jsoncEditor) newLeadingExtra(current hujson.Extra, depth int) hujson.Extra { standard := []byte(editor.eol + strings.Repeat(editor.indent, depth)) if bytes.Contains(current, []byte("//")) || bytes.Contains(current, []byte("/*")) { combined := append(hujson.Extra(nil), current...) return append(combined, standard...) } return standard } func findJSONCValue(root *hujson.Value, path []any, label string) (*hujson.Value, error) { current := root for _, part := range path { switch key := part.(type) { case string: object, ok := current.Value.(*hujson.Object) if !ok { return nil, fmt.Errorf("%s path %s must traverse an object", label, jsonPointer(path)) } found := false for index := range object.Members { if object.Members[index].Name.Value.(hujson.Literal).String() == key { current = &object.Members[index].Value found = true break } } if !found { return nil, fmt.Errorf("%s path %s does not exist", label, jsonPointer(path)) } case int: array, ok := current.Value.(*hujson.Array) if !ok || key < 0 || key >= len(array.Elements) { return nil, fmt.Errorf("%s array path %s is out of range", label, jsonPointer(path)) } current = &array.Elements[key] default: return nil, fmt.Errorf("unsupported %s path part %T", label, part) } } return current, nil } func jsonPointer(path []any) string { var pointer strings.Builder for _, part := range path { pointer.WriteByte('/') switch value := part.(type) { case string: value = strings.ReplaceAll(value, "~", "~0") pointer.WriteString(strings.ReplaceAll(value, "/", "~1")) case int: pointer.WriteString(strconv.Itoa(value)) } } return pointer.String() } func cloneJSONObject(value map[string]any) map[string]any { clone := make(map[string]any, len(value)) for key, item := range value { clone[key] = item } return clone }