371 lines
11 KiB
Go
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
|
|
}
|