package manifest import ( "fmt" "regexp" "slices" "sort" "strings" ) // The manifest is edited as text rather than decoded and written back. // // suite.toml carries more comment than data — the reasoning behind every topic // and every prefix lives there, and an encoder would drop all of it and reorder // what is left. So an entry is spliced into the source, and everything the // author wrote around it survives untouched. var ( tableRe = regexp.MustCompile(`^\s*\[([^\]]+)\]\s*$`) keyRe = regexp.MustCompile(`^\s*("[^"]+"|[A-Za-z0-9_-]+)\s*=`) bareRe = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) ) // AddEntry splices key = "value" into the given table of a TOML source. // // Where the entry lands follows what the table already does: a table whose keys // are in alphabetical order keeps it, and one ordered by hand — by directory, // by age, by whatever the author meant — gets the entry appended, because // guessing at that order would scatter it. func AddEntry(source []byte, table, key, value string) ([]byte, error) { lines := strings.Split(string(source), "\n") start := -1 for i, line := range lines { if m := tableRe.FindStringSubmatch(line); m != nil && m[1] == table { start = i break } } if start < 0 { return nil, fmt.Errorf("the manifest holds no table [%s]", table) } end := len(lines) for i := start + 1; i < len(lines); i++ { if tableRe.MatchString(lines[i]) { end = i break } } keys, at := tableKeys(lines, start+1, end) if slices.Contains(keys, key) { return nil, fmt.Errorf("the table [%s] already holds the key %s", table, key) } entry := renderEntry(key, value) insert := insertionPoint(keys, at, key, lines, start, end) out := make([]string, 0, len(lines)+1) out = append(out, lines[:insert]...) out = append(out, entry) out = append(out, lines[insert:]...) return []byte(strings.Join(out, "\n")), nil } // RemoveEntry drops a key from a table, leaving everything around it alone. // Together with AddEntry it moves an entry from the live half of a section to // the retired one, which is the only way a name ever leaves the live half. func RemoveEntry(source []byte, table, key string) ([]byte, error) { lines := strings.Split(string(source), "\n") start := -1 for i, line := range lines { if m := tableRe.FindStringSubmatch(line); m != nil && m[1] == table { start = i break } } if start < 0 { return nil, fmt.Errorf("the manifest holds no table [%s]", table) } end := len(lines) for i := start + 1; i < len(lines); i++ { if tableRe.MatchString(lines[i]) { end = i break } } keys, at := tableKeys(lines, start+1, end) for i, existing := range keys { if existing != key { continue } out := make([]string, 0, len(lines)-1) out = append(out, lines[:at[i]]...) out = append(out, lines[at[i]+1:]...) return []byte(strings.Join(out, "\n")), nil } return nil, fmt.Errorf("the table [%s] holds no key %s", table, key) } // tableKeys collects the keys of a table together with the line each sits on. func tableKeys(lines []string, from, to int) (keys []string, at []int) { for i := from; i < to; i++ { m := keyRe.FindStringSubmatch(lines[i]) if m == nil { continue } keys = append(keys, strings.Trim(m[1], `"`)) at = append(at, i) } return keys, at } // insertionPoint picks the line the entry goes before. func insertionPoint(keys []string, at []int, key string, lines []string, start, end int) int { if len(keys) == 0 { // An empty table owns the comments standing right under its header // and nothing further: a comment block separated by a blank line // belongs to the table header below it, not to this one. Walking to // the end of the section instead would file the entry under the wrong // explanation. i := start + 1 for i < end && strings.HasPrefix(strings.TrimSpace(lines[i]), "#") { i++ } return i } if sort.StringsAreSorted(keys) { for i, existing := range keys { if key < existing { return at[i] } } } // The entry goes after the last key, and a key is not always one line: an // array written down a column ends where its bracket closes. Appending // after the first line of it would land the entry inside the array. return valueEnd(lines, at[len(at)-1], end) + 1 } // valueEnd returns the last line the value of a key occupies. func valueEnd(lines []string, at, to int) int { depth := 0 for n := at; n < to; n++ { code, _ := splitComment(lines[n]) if n == at { if i := strings.Index(code, "="); i >= 0 { code = code[i+1:] } } _, next, closeAt := scanCode(code, depth) if closeAt >= 0 { return n } if depth = next; depth <= 0 { return n } } return at } // renderEntry writes one key-value line, quoting the key when it is not bare. func renderEntry(key, value string) string { if !bareRe.MatchString(key) { key = Quote(key) } return key + " = " + Quote(value) } // Quote writes a string the way TOML reads it back. Every value the tool puts // into a manifest goes through here: a Windows path is the ordinary case where // a backslash left alone makes the tool unable to read the file it just wrote. func Quote(s string) string { return `"` + escape(s) + `"` } func escape(s string) string { s = strings.ReplaceAll(s, `\`, `\\`) return strings.ReplaceAll(s, `"`, `\"`) }