Files
magpie/internal/edit/toml.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

654 lines
19 KiB
Go

package edit
import (
"bytes"
"errors"
"fmt"
"io/fs"
"strconv"
"strings"
"github.com/pelletier/go-toml/v2"
"github.com/pelletier/go-toml/v2/unstable"
"github.com/yetone/magpie/internal/filememo"
)
// Whole TOML tables are treated as units: magpie owns the tables it writes
// (for example a model provider) and replaces them verbatim, while every
// other line of the file stays untouched.
// TOMLTables lists explicit ordinary table headers in file order, excluding
// array-table headers. A name is the header's key path: a quoted part keeps its
// quotes, and whitespace around the dots is not part of it, so `[ a . b ]` is
// the name `a.b`. Missing files return (nil, nil); errors include the path.
func TOMLTables(path string) ([]string, error) {
tables, err := tomlTablesOf(path)
if err != nil {
return nil, err
}
var out []string
for _, table := range tables {
if !table.array {
out = append(out, table.name)
}
}
return out, nil
}
// GetTOMLTable returns the scalar keys of one table, or (nil, nil) when
// the file or table is absent. Read and parse errors are returned with the path.
// Strings are decoded; other scalars (booleans, numbers, dates, and times)
// retain their literal text. Arrays and inline tables are omitted.
// An array table with the requested name is a type error, not an absent table.
func GetTOMLTable(path, name string) (map[string]string, error) {
tables, err := tomlTablesOf(path)
if err != nil {
return nil, err
}
table, ok := tomlTableNamed(tables, name)
if !ok {
return nil, nil
}
if table.array {
return nil, fmt.Errorf("%s: line %d, column %d: %q is an array table, expected an ordinary table", path, table.from+1, table.column, name)
}
out := map[string]string{}
for _, kv := range table.keys {
if kv.scalar {
out[kv.name] = kv.value
}
}
return out, nil
}
// SetTOMLTable replaces table `name` with the given keys, or appends it.
// An existing array table with that name is rejected.
func SetTOMLTable(path, name string, kvs ...KV) error {
raw, err := Read(path)
if err != nil {
return err
}
block := []string{"[" + name + "]"}
for _, kv := range kvs {
block = append(block, kv.Path+" = "+tomlLiteral(kv.Value))
}
lines := splitLines(string(raw))
table, err := parseTOMLTable(lines, name)
if err != nil {
return fmt.Errorf("%s: %w", path, err)
}
from, to := table.from, table.to
var out []string
if from < 0 {
out = trimBlank(lines)
if len(out) > 0 {
out = append(out, "")
}
out = append(out, block...)
out = append(out, "")
} else {
out = append(out, lines[:from]...)
out = append(out, block...)
if to < len(lines) && strings.TrimSpace(lines[to]) != "" {
out = append(out, "")
}
out = append(out, lines[to:]...)
}
return writeTOML(path, out)
}
// SetTOMLKey sets one key of table `name` and leaves the table's other
// lines as they are. Replacing a value preserves its surrounding whitespace
// and trailing comment. New keys go after the last complete value; missing
// tables are appended, while an array table with the same name is rejected.
func SetTOMLKey(path, name, key string, value any) error {
raw, err := Read(path)
if err != nil {
return err
}
line := key + " = " + tomlLiteral(value)
lines := splitLines(string(raw))
table, err := parseTOMLTable(lines, name)
if err != nil {
return fmt.Errorf("%s: %w", path, err)
}
if table.from < 0 {
out := trimBlank(lines)
if len(out) > 0 {
out = append(out, "")
}
out = append(out, "["+name+"]", line, "")
return writeTOML(path, out)
}
from, to := table.from+1, table.from+1
for _, kv := range table.keys {
if kv.name == key {
from, to = kv.from, kv.to
line = kv.prefix + tomlLiteral(value) + kv.suffix
break
}
from, to = kv.to, kv.to
}
out := append(append(append([]string{}, lines[:from]...), line), lines[to:]...)
return writeTOML(path, out)
}
// DelTOMLKey removes one key of table `name`, and the table with it when
// nothing else is left in it.
func DelTOMLKey(path, name, key string) error {
raw, err := Read(path)
if err != nil || raw == nil {
return err
}
lines := splitLines(string(raw))
table, err := parseTOMLTable(lines, name)
if err != nil {
return fmt.Errorf("%s: %w", path, err)
}
if table.from < 0 {
return nil
}
out := append([]string{}, lines[:table.from+1]...)
at := table.from + 1
for _, kv := range table.keys {
if kv.name == key {
out = append(out, lines[at:kv.from]...)
at = kv.to
}
}
out = append(out, lines[at:table.to]...)
for _, line := range out[table.from+1:] {
if strings.TrimSpace(line) != "" {
out = append(out, lines[table.to:]...)
return writeTOML(path, out)
}
}
return DelTOMLTable(path, name)
}
// DelTOMLTable removes table `name` (header and body) if present.
// Child table and array-table blocks are outside that range and remain.
func DelTOMLTable(path, name string) error {
raw, err := Read(path)
if err != nil || raw == nil {
return err
}
lines := splitLines(string(raw))
table, err := parseTOMLTable(lines, name)
if err != nil {
return fmt.Errorf("%s: %w", path, err)
}
from, to := table.from, table.to
if from < 0 {
return nil
}
// Take the blank lines before the header with it so no gap is left.
for from > 0 && strings.TrimSpace(lines[from-1]) == "" {
from--
}
out := append([]string{}, lines[:from]...)
if to < len(lines) && len(out) > 0 && strings.TrimSpace(lines[to]) != "" {
out = append(out, "")
}
out = append(out, lines[to:]...)
return writeTOML(path, out)
}
// GetTOMLTop reads a top-level (pre-table) key from a TOML file. A string is
// decoded; any other value is its text as written, without a trailing
// comment, an array or inline table over several lines included. A file
// TOML's parser refuses is read line by line, as well as that goes.
func GetTOMLTop(path, key string) (string, bool) {
doc, err := tomlFileOf(path)
if err != nil {
return getTOMLTopLines(path, key)
}
for _, kv := range doc.root.keys {
if kv.name == key {
if kv.scalar {
return kv.value, true
}
return kv.text, true
}
}
return "", false
}
// getTOMLTopLines is GetTOMLTop for a file that isn't valid TOML: the first
// line that looks like `key = value` before one that looks like a header.
func getTOMLTopLines(path, key string) (string, bool) {
raw, err := Read(path)
if err != nil || raw == nil {
return "", false
}
for _, line := range splitLines(string(raw)) {
if tomlTable.MatchString(line) {
break
}
if m := tomlKV.FindStringSubmatch(line); m != nil && strings.Trim(m[1], `"`) == key {
return tomlValue(m[2]), true
}
}
return "", false
}
// SetTOMLTop sets top-level keys in a TOML file, as strings. An existing key
// is replaced as a whole, its value's every line with it; new keys go right
// after the last existing top-level key's value, or first in the file when
// there is none. Every key is located in the file as it was read, so the
// edit is one write that either happens entirely or not at all.
func SetTOMLTop(path string, kvs ...KV) error {
raw, err := Read(path)
if err != nil {
return err
}
lines := splitLines(string(raw))
doc, err := parseTOMLFile(lines)
if err != nil {
return fmt.Errorf("%s: %w", path, err)
}
type span struct {
to int
line string
}
replace := map[int]span{}
var added []string
addedAt := map[string]int{}
at := 0
if n := len(doc.root.keys); n > 0 {
at = doc.root.keys[n-1].to
}
for _, kv := range kvs {
line := kv.Path + " = " + strconv.Quote(toString(kv.Value))
found := false
for _, k := range doc.root.keys {
if k.name == kv.Path {
replace[k.from] = span{k.to, line}
found = true
break
}
}
switch i, ok := addedAt[kv.Path]; {
case found:
case ok:
added[i] = line
default:
addedAt[kv.Path] = len(added)
added = append(added, line)
}
}
out := make([]string, 0, len(lines)+len(added))
for i := 0; i < len(lines); {
if i == at {
out = append(out, added...)
added = nil
}
if r, ok := replace[i]; ok {
out = append(out, r.line)
i = r.to
continue
}
out = append(out, lines[i])
i++
}
out = append(out, added...)
return writeTOML(path, out)
}
// DelTOMLTop removes top-level (pre-table) keys, each with every line of its
// value.
func DelTOMLTop(path string, keys ...string) error {
raw, err := Read(path)
if err != nil || raw == nil {
return err
}
lines := splitLines(string(raw))
doc, err := parseTOMLFile(lines)
if err != nil {
return fmt.Errorf("%s: %w", path, err)
}
drop := map[string]bool{}
for _, k := range keys {
drop[k] = true
}
var out []string
at := 0
for _, kv := range doc.root.keys {
if drop[kv.name] {
out = append(out, lines[at:kv.from]...)
at = kv.to
}
}
out = append(out, lines[at:]...)
return writeTOML(path, out)
}
type tomlTableSpan struct {
from, to int
column int // 1-based start of the table name.
name string
array bool
keys []tomlKeySpan
}
type tomlKeySpan struct {
from, to int // to is -1 until the next expression or EOF bounds the value.
column int // 1-based start of the key.
name, value string
text string // The value as written, from prefix to suffix.
prefix, suffix string // Original text before and after the complete value.
comment int // Column of a trailing comment, or -1 when absent.
scalar bool
}
// parseTOMLTable locates a table and its complete key/value expressions in
// the original lines. Ranges are [from, to); the table's from is -1 when absent.
// A child array table is a separate block, outside the target table's range;
// keeping it can implicitly recreate the parent after the parent is deleted.
func parseTOMLTable(lines []string, name string) (tomlTableSpan, error) {
tables, err := parseTOMLTables(lines)
if err != nil {
return tomlTableSpan{}, err
}
if table, ok := tomlTableNamed(tables, name); ok {
if table.array {
return tomlTableSpan{}, fmt.Errorf("line %d, column %d: %q is an array table, expected an ordinary table", table.from+1, table.column, name)
}
return table, nil
}
return tomlTableSpan{from: -1, to: -1}, nil
}
func tomlTableNamed(tables []tomlTableSpan, name string) (tomlTableSpan, bool) {
for _, table := range tables {
if table.name == name {
return table, true
}
}
return tomlTableSpan{}, false
}
// tomlTablesOf is parseTOMLTables of a file, parsed again only once it
// changes: an agent's config can be large (Codex's lists every project it
// was trusted in) and is read field by field. Missing files are (nil, nil).
func tomlTablesOf(path string) ([]tomlTableSpan, error) {
doc, err := tomlFileOf(path)
return doc.tables, err
}
// tomlFileOf is parseTOMLFile of a file, kept as tomlTablesOf says. A missing
// file is an empty document and no error.
func tomlFileOf(path string) (tomlFile, error) {
doc, err := filememo.Read("toml file", path, func(b []byte) (tomlFile, error) {
doc, err := parseTOMLFile(splitLines(string(b)))
if err != nil {
return tomlFile{}, fmt.Errorf("%s: %w", path, err)
}
return doc, nil
})
if errors.Is(err, fs.ErrNotExist) {
return tomlFile{}, nil
}
return doc, err
}
// parseTOMLTables finds both ordinary and array-table blocks in one parser pass.
// Only top-level expressions delimit edits, so lines inside multiline values
// cannot be mistaken for keys, comments, or table headers.
func parseTOMLTables(lines []string) ([]tomlTableSpan, error) {
doc, err := parseTOMLFile(lines)
return doc.tables, err
}
// tomlFile is a file's top-level (pre-table) keys, as the unnamed table root
// spanning [0, first header), and its table blocks in file order.
type tomlFile struct {
root tomlTableSpan
tables []tomlTableSpan
}
// parseTOMLFile is parseTOMLTables with the top-level keys too, located the
// same way: a key's range covers its whole value, however many lines it takes.
func parseTOMLFile(lines []string) (tomlFile, error) {
p := unstable.Parser{KeepComments: true}
p.Reset([]byte(strings.Join(lines, "\n")))
root := tomlTableSpan{to: len(lines)}
var tables []tomlTableSpan
current := func() *tomlTableSpan {
if len(tables) == 0 {
return &root
}
return &tables[len(tables)-1]
}
finishKey := func(to int) error {
table := current()
if len(table.keys) == 0 {
return nil
}
kv := &table.keys[len(table.keys)-1]
if kv.to != -1 {
return nil
}
// Array nodes have no complete Raw range. The next expression (including
// standalone comments) bounds the value; only trailing blank lines remain.
kv.to = to
for kv.to > kv.from+1 && strings.TrimSpace(lines[kv.to-1]) == "" {
kv.to--
}
if kv.to <= kv.from {
return fmt.Errorf("line %d, column %d: cannot locate the end of the TOML value", kv.from+1, kv.column)
}
last := lines[kv.to-1]
end := len(last)
if kv.comment >= 0 {
end = kv.comment
}
// Comments come from parser nodes, so '#' inside a string is part of
// the value. Keep all whitespace between the closing value and comment.
end = len(strings.TrimRight(last[:end], " \t\r"))
kv.suffix = last[end:]
text := strings.Join(lines[kv.from:kv.to], "\n")
kv.text = text[len(kv.prefix) : len(text)-len(kv.suffix)]
return nil
}
for p.NextExpression() {
expr := p.Expression()
keyRange := expr.Raw
var parts, rawParts []string
if expr.Kind != unstable.Comment {
for keys := expr.Key(); keys.Next(); {
key := keys.Node()
if len(parts) == 0 {
keyRange = key.Raw
}
parts = append(parts, string(key.Data))
// A table's name is the parser's own key text, so a quoted part keeps
// its quotes, while the whitespace a header may put around its dots,
// as in `[ a . b ]`, is not part of the name.
rawParts = append(rawParts, string(p.Raw(key.Raw)))
keyRange.Length = key.Raw.Offset + key.Raw.Length - keyRange.Offset
}
}
shape := p.Shape(keyRange)
i := shape.Start.Line - 1
if err := finishKey(i); err != nil {
return tomlFile{}, err
}
switch expr.Kind {
case unstable.Table, unstable.ArrayTable:
current().to = i
tables = append(tables, tomlTableSpan{
from: i, to: len(lines), name: strings.Join(rawParts, "."),
column: shape.Start.Column,
array: expr.Kind == unstable.ArrayTable,
})
case unstable.KeyValue:
table := current()
value := expr.Value()
// Start after the parsed key, which may itself contain '='.
_, valueText, _ := strings.Cut(lines[i][shape.End.Column-1:], "=")
start := len(lines[i]) - len(strings.TrimLeft(valueText, " \t"))
kv := tomlKeySpan{
from: i, to: -1, name: strings.Join(parts, "."),
column: shape.Start.Column,
prefix: lines[i][:start], comment: -1,
value: string(value.Data), scalar: value.Kind != unstable.Array && value.Kind != unstable.InlineTable,
}
if comment := expr.Next(); comment != nil && comment.Kind == unstable.Comment {
kv.comment = p.Shape(comment.Raw).Start.Column - 1
}
table.keys = append(table.keys, kv)
}
}
if err := p.Error(); err != nil {
return tomlFile{}, tomlParseError(&p, err)
}
if err := finishKey(len(lines)); err != nil {
return tomlFile{}, err
}
return tomlFile{root: root, tables: tables}, nil
}
func tomlParseError(p *unstable.Parser, err error) error {
pe, ok := err.(*unstable.ParserError)
if !ok {
return err
}
data := p.Data()
// Empty highlights are used for EOF errors. Do not pass them to Range:
// the parser's slice-offset calculation can panic on a nil highlight.
pos := unstable.Position{
Offset: len(data), Line: bytes.Count(data, []byte{'\n'}) + 1,
Column: len(data) - bytes.LastIndexByte(data, '\n'),
}
if len(pe.Highlight) > 0 {
pos = p.Shape(p.Range(pe.Highlight)).Start
}
if pos.Offset == 0 && bytes.HasPrefix(data, []byte{0xef, 0xbb, 0xbf}) {
err = fmt.Errorf("UTF-8 BOM is not supported; save the file without BOM: %w", err)
}
return fmt.Errorf("line %d, column %d: %w", pos.Line, pos.Column, err)
}
// writeTOML validates the exact bytes before any filesystem changes. Decoding
// also checks duplicate keys and conflicting definitions; it never rewrites
// the document, so comments and formatting survive validation unchanged.
func writeTOML(path string, lines []string) error {
data := []byte(joinLines(lines))
if err := validateTOML(data); err != nil {
return fmt.Errorf("%s: edited TOML is invalid: %w", path, err)
}
return WriteAtomic(path, data)
}
func validateTOML(data []byte) error {
// Check syntax first so EOF errors use the same nil-highlight-safe
// diagnostics as reads, before the decoder checks TOML definitions.
var p unstable.Parser
p.Reset(data)
for p.NextExpression() {
}
if err := p.Error(); err != nil {
return tomlParseError(&p, err)
}
var decoded map[string]any
if err := toml.Unmarshal(data, &decoded); err != nil {
if de, ok := err.(*toml.DecodeError); ok {
line, column := de.Position()
return fmt.Errorf("line %d, column %d: %w", line, column, err)
}
return err
}
return nil
}
func trimBlank(lines []string) []string {
for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
lines = lines[:len(lines)-1]
}
return lines
}
// Raw is a TOML value written as it is: an array or an inline table the
// caller has already spelled out.
type Raw string
func tomlLiteral(v any) string {
switch x := v.(type) {
case Raw:
return string(x)
case bool:
return strconv.FormatBool(x)
case int:
return strconv.Itoa(x)
}
return strconv.Quote(toString(v))
}
// Table is one TOML table: its header name and its keys, in order.
type Table struct {
Name string
KVs []KV
}
// SetTOMLTables replaces every ordinary or array table whose name begins with
// one of the prefixes by the given tables, appended at the end in one write: a set of
// tables magpie owns as a whole (one per model of its catalog), which would
// otherwise be rewritten once per table. With no tables it only removes.
func SetTOMLTables(path string, prefixes []string, tables []Table) error {
raw, err := Read(path)
if err != nil {
return err
}
lines := splitLines(string(raw))
blocks, err := parseTOMLTables(lines)
if err != nil {
return fmt.Errorf("%s: %w", path, err)
}
owned := func(name string) bool {
for _, p := range prefixes {
if strings.HasPrefix(name, p) {
return true
}
}
return false
}
top := len(lines)
if len(blocks) > 0 {
top = blocks[0].from
}
out := append([]string{}, lines[:top]...)
drop, found := false, false
for _, block := range blocks {
was := drop
drop = owned(block.name)
if drop {
found = true
out = trimBlank(out)
} else if was && len(out) > 0 {
out = append(out, "")
}
if !drop {
out = append(out, lines[block.from:block.to]...)
}
}
if !found && len(tables) == 0 {
return nil
}
out = trimBlank(out)
for _, t := range tables {
if len(out) > 0 {
out = append(out, "")
}
out = append(out, "["+t.Name+"]")
for _, kv := range t.KVs {
out = append(out, kv.Path+" = "+tomlLiteral(kv.Value))
}
}
if len(out) > 0 {
out = append(out, "")
}
return writeTOML(path, out)
}