Files
magpie/internal/edit/text.go
T
yetone d37b821908 toml: Codex's config.toml with a multi-line array (notify = [ … ]) is edited right, and an edit that fails leaves every file as it was (#253 kerwinliu00001: "edited TOML is invalid: line 2, column 1: incomplete number", config.toml left with model_provider gone and [model_providers.magpie] added but no model) — the top-level key edits (GetTOMLTop, SetTOMLTop, DelTOMLTop) went line by line with regexes: a new key went after the last line that looked like key = …, which for notify = [ was inside the array; key = value lines inside a multi-line string were read as keys and a line starting with [ as a table; replacing or deleting a multi-line value touched only its first line. They now locate the top-level keys with the TOML parser, as the table edits already did, each with its value's whole range however many lines it takes (parseTOMLFile, the unnamed root table beside the tables); SetTOMLTop finds all its keys in the file as read and writes once. A file the parser refuses is still read line by line, so a broken config keeps showing its model, and writes to it are refused untouched. And a pick is several writes (Codex: drop model_provider, add magpie's provider table, set openai_base_url and model), each checked, so one failing left the earlier ones on disk: edit.Atomically(fn, paths…) puts each file back to its bytes and mode, or removes one it created, when fn fails, and every field set and Sync of Codex (config.toml and magpie-models.json, so Codex in WSL too), Kimi and Grok goes through it, as do Codex's MCP server writes (putCodex, delCodex). Tested in temp dirs and a temp HOME: the reporter's file read, set and deleted directly and through Codex's model field (signed in, with model_provider and model above the array, with magpie as the provider), the array's text kept and the TOML valid, and back to a Codex model; nested arrays, trailing commas, comments in arrays, [x] and a = b in strings, an inline table in an array; """ and ''' strings holding model = … and [table] lines not read as keys, new keys after their closing quotes, one deleted whole; a forced failure of Codex's last write leaving config.toml byte-identical; TestAtomically. They fail on main (the Codex ones with the reporter's error). go vet ./internal/..., go build -tags nogui (and GOOS=linux, GOOS=windows) and go test -tags nogui ./... pass. Not tried against a real ~/.codex, in WSL or in the GUI; multi-line inline tables (TOML 1.1) are refused by the parser and so left untouched with an error.
2026-09-30 09:31:59 +08:00

179 lines
4.1 KiB
Go

package edit
import (
"fmt"
"regexp"
"strconv"
"strings"
)
// Top-level YAML keys are edited line by line. That keeps every comment and
// every other line byte-for-byte intact, which a round trip through a parser
// would not. TOML's are located by its parser (see toml.go), since a value
// there can run over several lines.
var (
// tomlTable and tomlKV only read a TOML file its parser refuses; see
// GetTOMLTop.
tomlTable = regexp.MustCompile(`^\s*\[`)
tomlKV = regexp.MustCompile(`^\s*([A-Za-z0-9_.-]+|"[^"]*")\s*=\s*(.*?)\s*$`)
yamlKV = regexp.MustCompile(`^([A-Za-z0-9_.-]+)\s*:\s*(.*?)\s*$`)
yamlPlain = regexp.MustCompile(`^[A-Za-z0-9._/-]+$`)
)
// GetYAMLTop reads a top-level scalar key from a YAML file.
func GetYAMLTop(path, key string) (string, bool) {
raw, err := Read(path)
if err != nil || raw == nil {
return "", false
}
for _, line := range splitLines(string(raw)) {
if m := yamlKV.FindStringSubmatch(line); m != nil && m[1] == key {
return yamlValue(m[2]), true
}
}
return "", false
}
// SetYAMLTop sets top-level scalar keys in a YAML file.
func SetYAMLTop(path string, kvs ...KV) error {
raw, err := Read(path)
if err != nil {
return err
}
lines := splitLines(string(raw))
for _, kv := range kvs {
v := toString(kv.Value)
if !yamlPlain.MatchString(v) {
v = strconv.Quote(v)
}
lines = setLine(lines, kv.Path, kv.Path+": "+v, nil, func(line string) (string, bool) {
m := yamlKV.FindStringSubmatch(line)
if m == nil {
return "", false
}
return m[1], true
})
}
return WriteAtomic(path, []byte(joinLines(lines)))
}
// DelYAMLTop removes top-level scalar keys from a YAML file.
func DelYAMLTop(path string, keys ...string) error {
raw, err := Read(path)
if err != nil || raw == nil {
return err
}
drop := map[string]bool{}
for _, k := range keys {
drop[k] = true
}
var out []string
for _, line := range splitLines(string(raw)) {
if m := yamlKV.FindStringSubmatch(line); m != nil && drop[m[1]] {
continue
}
out = append(out, line)
}
return WriteAtomic(path, []byte(joinLines(out)))
}
// setLine replaces the line whose key matches, or inserts newLine after the
// last key line in the header section (before the first line matching stop).
func setLine(lines []string, key, newLine string, stop *regexp.Regexp, keyOf func(string) (string, bool)) []string {
lastKey := -1
for i, line := range lines {
if stop != nil && stop.MatchString(line) {
break
}
k, ok := keyOf(line)
if !ok {
continue
}
if k == key {
lines[i] = newLine
return lines
}
lastKey = i
}
at := lastKey + 1
out := make([]string, 0, len(lines)+1)
out = append(out, lines[:at]...)
out = append(out, newLine)
out = append(out, lines[at:]...)
return out
}
// unquote strips a leading quoted scalar (and anything after it, such as an
// inline comment); ok is false when v does not start with a quote.
func unquote(v string) (string, bool) {
if v == "" || (v[0] != '"' && v[0] != '\'') {
return "", false
}
q := v[0]
for i := 1; i < len(v); i++ {
if q == '"' && v[i] == '\\' {
i++
continue
}
if v[i] == q {
if q == '"' {
if s, err := strconv.Unquote(v[:i+1]); err == nil {
return s, true
}
}
return v[1:i], true
}
}
return v[1:], true
}
func tomlValue(v string) string {
if s, ok := unquote(v); ok {
return s
}
if i := strings.Index(v, "#"); i >= 0 {
v = strings.TrimSpace(v[:i])
}
return v
}
func yamlValue(v string) string {
if s, ok := unquote(v); ok {
return s
}
if i := strings.Index(v, " #"); i >= 0 {
v = strings.TrimSpace(v[:i])
}
return v
}
func toString(v any) string {
switch x := v.(type) {
case string:
return x
case bool:
return strconv.FormatBool(x)
case int:
return strconv.Itoa(x)
}
return fmt.Sprint(v)
}
// splitLines splits on '\n' but keeps a trailing newline as an empty final
// element so joinLines can restore the file exactly.
func splitLines(s string) []string {
if s == "" {
return []string{""}
}
return strings.Split(s, "\n")
}
func joinLines(lines []string) string {
s := strings.Join(lines, "\n")
if !strings.HasSuffix(s, "\n") {
s += "\n"
}
return s
}