Files
UnrealPrototyping/Tools/Terrain/internal/studio/patch.go
T
2026-09-25 17:02:24 +03:00

371 lines
11 KiB
Go

package studio
import (
"fmt"
"strconv"
"strings"
)
// Editing a legend in place, without reformatting it.
//
// The obvious way to save a legend the studio has changed is to unmarshal it, set the fields and marshal it
// back. That destroys the file. A legend is mostly *commentary* - the `_comment_massif` block on `lowland` is
// four lines explaining why its floor is a tenth of its rate - and unmarshalling into the Class struct drops
// every underscore key on the floor. Unmarshalling into map[string]any keeps them and loses the order
// instead, because encoding/json sorts map keys, so the hand-laid table comes back alphabetised with every
// comment moved away from the thing it was commenting on.
//
// The same argument the palette writer already makes, one file over: this repository does not let
// MarshalIndent near a file a person wrote. So the studio patches the *text*. It finds the object for a named
// class and replaces one key's value inside it, or inserts the key if it is not there, and every byte it did
// not deliberately change comes out identical. That also means a legend edited here still diffs usefully,
// which for a file under review is most of the point.
// patchClassNumber sets one numeric key on one class, adding it if it is absent. The returned text is the
// input with exactly that value changed.
func patchClassNumber(src, class, key string, value float64) (string, error) {
start, end, err := classObject(src, class)
if err != nil {
return "", err
}
body := src[start:end]
num := formatNumber(value)
if ks, ke, ok := keyValue(body, key); ok {
return src[:start] + body[:ks] + fmt.Sprintf("%q: %s", key, num) + body[ke:] + src[end:], nil
}
// Not present: put it after the class's name, which is where a reader looks for it and which every class
// is guaranteed to have.
ns, ne, ok := keyValue(body, "name")
if !ok {
return "", fmt.Errorf("class %q has no name key to insert %q after", class, key)
}
_ = ns
ins := fmt.Sprintf(", %q: %s", key, num)
return src[:start] + body[:ne] + ins + body[ne:] + src[end:], nil
}
// patchClassRemove deletes one key from one class, taking its separating comma with it. Absent is not an
// error: the studio sends "this mark no longer says anything about the coast" whether or not it ever did.
func patchClassRemove(src, class, key string) (string, error) {
start, end, err := classObject(src, class)
if err != nil {
return "", err
}
body := src[start:end]
ks, ke, ok := keyValue(body, key)
if !ok {
return src, nil
}
// A key goes with exactly one of the two separators around it, and which one depends on where it sits.
// The test is a round trip: adding a key and taking it away again has to give the file back byte for
// byte, or every later diff carries the scar of a setting somebody tried once.
s, e := ks, ke
j := e
for j < len(body) && isJSONSpace(body[j]) {
j++
}
if j < len(body) && body[j] == ',' {
// Not the last key: take the comma after it, and the space that followed that comma in place of the
// one that preceded this key.
e = j + 1
if e < len(body) && body[e] == ' ' && s > 0 && body[s-1] == ' ' {
e++
}
} else {
// The last key in the object: there is no comma after it, so take the one before - and nothing
// forward, or the space in front of the closing brace goes with it.
for s > 0 && isJSONSpace(body[s-1]) {
s--
}
if s > 0 && body[s-1] == ',' {
s--
}
}
return src[:start] + body[:s] + body[e:] + src[end:], nil
}
func isJSONSpace(c byte) bool { return c == ' ' || c == '\n' || c == '\r' || c == '\t' }
// patchClassObject sets one object-valued key on one class - the massif block - or removes it when nil.
func patchClassObject(src, class, key string, fields map[string]float64, order []string) (string, error) {
start, end, err := classObject(src, class)
if err != nil {
return "", err
}
body := src[start:end]
var lit string
if fields != nil {
parts := make([]string, 0, len(order))
for _, k := range order {
v, ok := fields[k]
if !ok {
continue
}
parts = append(parts, fmt.Sprintf("%q: %s", k, formatNumber(v)))
}
lit = fmt.Sprintf("%q: { %s }", key, strings.Join(parts, ", "))
}
if ks, ke, ok := keyValue(body, key); ok {
if lit == "" {
// Removing it: take the separating comma with it, whichever side it is on.
s, e := ks, ke
for e < len(body) && (body[e] == ' ' || body[e] == '\n' || body[e] == '\r' || body[e] == '\t') {
e++
}
if e < len(body) && body[e] == ',' {
e++
} else {
for s > 0 && (body[s-1] == ' ' || body[s-1] == '\n' || body[s-1] == '\r' || body[s-1] == '\t') {
s--
}
if s > 0 && body[s-1] == ',' {
s--
}
}
return src[:start] + body[:s] + body[e:] + src[end:], nil
}
return src[:start] + body[:ks] + lit + body[ke:] + src[end:], nil
}
if lit == "" {
return src, nil // asked to remove something that is not there
}
// Inserted at the end of the class object, on a line of its own. Straight after the name would read
// better in a one-line class and reads badly in exactly the ones that matter: a class carrying
// commentary is written over several lines, and splicing into the middle of the first one leaves the
// rest of that line dangling behind the insertion.
brace := len(body) - 1
for brace > 0 && body[brace] != '}' {
brace--
}
head := strings.TrimRight(body[:brace], " \t\r\n")
return src[:start] + head + ",\n " + lit + "\n " + body[brace:] + src[end:], nil
}
// patchTopNumber sets a numeric key inside a named top-level object, such as the manifest's planet block.
func patchTopNumber(src, object, key string, value float64) (string, error) {
os, oe, err := objectAfterKey(src, object, 0)
if err != nil {
return "", err
}
body := src[os:oe]
num := formatNumber(value)
if ks, ke, ok := keyValue(body, key); ok {
return src[:os] + body[:ks] + fmt.Sprintf("%q: %s", key, num) + body[ke:] + src[oe:], nil
}
// Insert just inside the opening brace, on its own line.
return src[:os+1] + fmt.Sprintf("\n %q: %s,", key, num) + src[os+1:], nil
}
// patchTopString is patchTopNumber for a string value.
func patchTopString(src, object, key, value string) (string, error) {
os, oe, err := objectAfterKey(src, object, 0)
if err != nil {
return "", err
}
body := src[os:oe]
if ks, ke, ok := keyValue(body, key); ok {
return src[:os] + body[:ks] + fmt.Sprintf("%q: %q", key, value) + body[ke:] + src[oe:], nil
}
return src[:os+1] + fmt.Sprintf("\n %q: %q,", key, value) + src[os+1:], nil
}
// classObject is the byte range of the object in the classes array whose "name" is the one asked for,
// from its opening brace to just past its closing one.
func classObject(src, class string) (start, end int, err error) {
want := fmt.Sprintf("%q", class)
from := 0
for {
i := indexKeyValue(src, "name", want, from)
if i < 0 {
return 0, 0, fmt.Errorf("no class named %q in the legend", class)
}
// Walk back to the opening brace of the object this key sits in.
depth := 0
j := i
for ; j >= 0; j-- {
switch src[j] {
case '}':
depth++
case '{':
if depth == 0 {
s, e, ok := matchBrace(src, j)
if ok {
return s, e, nil
}
return 0, 0, fmt.Errorf("class %q: unbalanced braces", class)
}
depth--
}
}
from = i + 1
}
}
// objectAfterKey is the byte range of the object that is the value of the given key.
func objectAfterKey(src, key string, from int) (start, end int, err error) {
i := indexKey(src, key, from)
if i < 0 {
return 0, 0, fmt.Errorf("no %q object", key)
}
j := i
for j < len(src) && src[j] != '{' {
if src[j] == ',' || src[j] == '}' {
return 0, 0, fmt.Errorf("%q is not an object", key)
}
j++
}
if j >= len(src) {
return 0, 0, fmt.Errorf("%q is not an object", key)
}
s, e, ok := matchBrace(src, j)
if !ok {
return 0, 0, fmt.Errorf("%q: unbalanced braces", key)
}
return s, e, nil
}
// keyValue finds "key": value inside a body and returns the range covering both, value included.
func keyValue(body, key string) (start, end int, ok bool) {
i := indexKey(body, key, 0)
if i < 0 {
return 0, 0, false
}
// Past the colon, then over the value.
j := i
for j < len(body) && body[j] != ':' {
j++
}
j++
for j < len(body) && (body[j] == ' ' || body[j] == '\t' || body[j] == '\n' || body[j] == '\r') {
j++
}
if j >= len(body) {
return 0, 0, false
}
switch body[j] {
case '{':
_, e, ok := matchBrace(body, j)
if !ok {
return 0, 0, false
}
return i, e, true
case '[':
depth, k := 0, j
for ; k < len(body); k++ {
if body[k] == '[' {
depth++
} else if body[k] == ']' {
depth--
if depth == 0 {
return i, k + 1, true
}
}
}
return 0, 0, false
case '"':
k := j + 1
for ; k < len(body); k++ {
if body[k] == '\\' {
k++
continue
}
if body[k] == '"' {
return i, k + 1, true
}
}
return 0, 0, false
default:
k := j
for k < len(body) && body[k] != ',' && body[k] != '}' && body[k] != '\n' {
k++
}
// A bare number or keyword ends where the scan stopped, but the scan does not stop on a space, so
// `"width_m": 8 }` would otherwise hand back the space in front of the brace as part of the value -
// and every edit of a last-in-object key would quietly close it up to `8}`.
for k > j && isJSONSpace(body[k-1]) {
k--
}
return i, k, true
}
}
// indexKey finds the offset of a "key" token at object level, skipping any inside a string value.
func indexKey(s, key string, from int) int {
needle := fmt.Sprintf("%q", key)
for i := from; ; {
j := strings.Index(s[i:], needle)
if j < 0 {
return -1
}
at := i + j
// It is a key only if the next non-space character is a colon.
k := at + len(needle)
for k < len(s) && (s[k] == ' ' || s[k] == '\t') {
k++
}
if k < len(s) && s[k] == ':' {
return at
}
i = at + len(needle)
}
}
// indexKeyValue finds a "key": "value" pair and returns the offset of the key.
func indexKeyValue(s, key, quotedValue string, from int) int {
for i := from; ; {
at := indexKey(s, key, i)
if at < 0 {
return -1
}
_, e, ok := keyValue(s[at:], key)
if ok {
seg := strings.TrimSpace(s[at : at+e])
if strings.HasSuffix(seg, quotedValue) {
return at
}
}
i = at + 1
}
}
// matchBrace returns the range of the object opening at i.
func matchBrace(s string, i int) (start, end int, ok bool) {
depth := 0
inStr := false
for j := i; j < len(s); j++ {
c := s[j]
if inStr {
if c == '\\' {
j++
} else if c == '"' {
inStr = false
}
continue
}
switch c {
case '"':
inStr = true
case '{':
depth++
case '}':
depth--
if depth == 0 {
return i, j + 1, true
}
}
}
return 0, 0, false
}
// formatNumber writes a number the way a person would: no exponent, no trailing zeros, and never bare "0."
func formatNumber(v float64) string {
s := strconv.FormatFloat(v, 'f', -1, 64)
if s == "-0" {
return "0"
}
return s
}