Tooling
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user