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 }