package manifest import ( "fmt" "regexp" "slices" "strings" ) // A subscription is an array rather than a key-value pair, and the project // manifest is edited as text for the same reason the suite one is: what the // author wrote around the data — which component is what, why a topic is taken // — has to survive the tool touching the file. // // So the array is read as the manifest wrote it, not as a regular expression // over the raw lines: a # inside a string does not open a comment, a bracket // inside a comment does not close an array, and a name inside a comment is not // a subscription. Getting any of the three wrong turns a comment into data, and // there is no way back from that. // // The shape survives too. An array written on one line stays on one line, one // written down a column stays a column, and the comments keep their places: a // comment block above a value belongs to that value — the same rule that // governs a comment above a table — so sorting carries it along, while a // comment trailing on the same line stays on its line. var arrayKeyRe = regexp.MustCompile(`^(\s*)("[^"]+"|[A-Za-z0-9_-]+)\s*=\s*\[`) // element is one value of an array together with what was written around it. type element struct { value string above []string after string } // array is an array of the manifest, parsed. type array struct { indent string key string // column says the array was written down a column rather than on one line. column bool elems []element // opening is a comment trailing the opening bracket. opening string // dangling holds comment lines standing after the last value. dangling []string // tail is whatever follows the closing bracket. tail string } // AddToList adds a value to the array under key in the given table. A table // that has no such key gets one holding the single value. func AddToList(source []byte, table, key, value string) ([]byte, error) { lines := strings.Split(string(source), "\n") start, end, ok := tableBounds(lines, table) if !ok { return nil, fmt.Errorf("the manifest holds no table [%s]", table) } from, found := arrayKeyLine(lines, start+1, end, key) if !found { keys, at := tableKeys(lines, start+1, end) insert := insertionPoint(keys, at, key, lines, start, end) entry := fmt.Sprintf(`%s = ["%s"]`, key, escape(value)) 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 } a, last, ok := readArray(lines, from, end) if !ok { return nil, fmt.Errorf("the array %s in [%s] is not closed by a bracket", key, table) } if slices.ContainsFunc(a.elems, func(e element) bool { return e.value == value }) { return nil, fmt.Errorf("%s already holds %q", key, value) } sorted := slices.IsSortedFunc(a.elems, byValue) a.elems = append(a.elems, element{value: value}) if sorted { slices.SortStableFunc(a.elems, byValue) } return splice(lines, from, last, a.render()), nil } // RemoveFromList drops a value from the array under key. It is the other half // of AddToList: unsubscribing is not a command yet, and a pair of edits where // only one direction is written is a pair where the untried direction is wrong. func RemoveFromList(source []byte, table, key, value string) ([]byte, error) { lines := strings.Split(string(source), "\n") start, end, ok := tableBounds(lines, table) if !ok { return nil, fmt.Errorf("the manifest holds no table [%s]", table) } from, found := arrayKeyLine(lines, start+1, end, key) if !found { return nil, fmt.Errorf("the table [%s] holds no key %s", table, key) } a, last, ok := readArray(lines, from, end) if !ok { return nil, fmt.Errorf("the array %s in [%s] is not closed by a bracket", key, table) } i := slices.IndexFunc(a.elems, func(e element) bool { return e.value == value }) if i < 0 { return nil, fmt.Errorf("%s does not hold %q", key, value) } // The comment above a value went with it and goes away with it; a comment // left hanging over the next value would say the wrong thing about it. a.elems = slices.Delete(a.elems, i, i+1) return splice(lines, from, last, a.render()), nil } func byValue(a, b element) int { return strings.Compare(a.value, b.value) } // AddTable appends a table to the end of the manifest. A new component is a new // table, and it goes last because the order of components is the author's: // there is nothing to sort them by that would mean anything. func AddTable(source []byte, table string, entries [][2]string) ([]byte, error) { lines := strings.Split(string(source), "\n") if _, _, ok := tableBounds(lines, table); ok { return nil, fmt.Errorf("the manifest already holds the table [%s]", table) } for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" { lines = lines[:len(lines)-1] } block := []string{"", "[" + table + "]"} for _, e := range entries { block = append(block, e[0]+" = "+e[1]) } block = append(block, "") return []byte(strings.Join(append(lines, block...), "\n")), nil } // tableBounds finds the lines a table spans: its header and the line the next // table starts on. func tableBounds(lines []string, table string) (start, end int, ok bool) { start = -1 for i, line := range lines { if m := tableRe.FindStringSubmatch(line); m != nil && m[1] == table { start = i break } } if start < 0 { return 0, 0, false } end = len(lines) for i := start + 1; i < len(lines); i++ { if tableRe.MatchString(lines[i]) { end = i break } } return start, end, true } // arrayKeyLine finds the line an array opens on. func arrayKeyLine(lines []string, from, to int, key string) (int, bool) { for i := from; i < to; i++ { code, _ := splitComment(lines[i]) m := arrayKeyRe.FindStringSubmatch(code) if m != nil && strings.Trim(m[2], `"`) == key { return i, true } } return 0, false } // readArray parses the array opening on line from and returns the line it // closes on. func readArray(lines []string, from, to int) (a array, last int, ok bool) { code, comment := splitComment(lines[from]) m := arrayKeyRe.FindStringSubmatch(code) if m == nil { return a, 0, false } a.indent, a.key = m[1], strings.Trim(m[2], `"`) open := strings.Index(code, "[") values, depth, closeAt := scanCode(code[open:], 0) for _, v := range values { a.elems = append(a.elems, element{value: v}) } if closeAt >= 0 { a.tail = joinTail(code[open+closeAt:], comment) return a, from, true } a.column = true a.opening = comment var pending []string for n := from + 1; n < to; n++ { code, comment := splitComment(lines[n]) values, next, closeAt := scanCode(code, depth) depth = next if len(values) == 0 && strings.TrimSpace(code) == "" && closeAt < 0 { if comment != "" { pending = append(pending, lines[n]) } continue } for i, v := range values { e := element{value: v} if i == 0 { e.above, pending = pending, nil } if i == len(values)-1 && closeAt < 0 { e.after = comment } a.elems = append(a.elems, e) } if closeAt >= 0 { a.dangling = pending a.tail = joinTail(code[closeAt:], comment) return a, n, true } } return a, 0, false } // render writes the array back in the shape it had. func (a array) render() []string { quoted := make([]string, len(a.elems)) for i, e := range a.elems { quoted[i] = `"` + escape(e.value) + `"` } if !a.column { line := fmt.Sprintf("%s%s = [%s]", a.indent, a.key, strings.Join(quoted, ", ")) return []string{appendTail(line, a.tail)} } out := []string{appendTail(a.indent+a.key+" = [", a.opening)} for i, e := range a.elems { out = append(out, e.above...) out = append(out, appendTail(a.indent+" "+quoted[i]+",", e.after)) } out = append(out, a.dangling...) return append(out, appendTail(a.indent+"]", a.tail)) } func appendTail(line, tail string) string { if tail == "" { return line } return line + " " + tail } func joinTail(rest, comment string) string { return strings.TrimSpace(strings.TrimSpace(rest) + " " + comment) } // splitComment cuts a line into its code and its comment. A # inside a string // opens no comment, which is the whole reason this is not a call to strings.Cut. func splitComment(line string) (code, comment string) { quote := byte(0) for i := 0; i < len(line); i++ { c := line[i] if quote != 0 { if quote == '"' && c == '\\' { i++ continue } if c == quote { quote = 0 } continue } switch c { case '"', '\'': quote = c case '#': return line[:i], line[i:] } } return line, "" } // scanCode walks the code of a line, collecting the strings in it and following // the bracket depth. closeAt is the offset just past the bracket that brought // the depth back to zero, or -1 while the array is still open. func scanCode(code string, depth int) (values []string, depthOut, closeAt int) { closeAt = -1 for i := 0; i < len(code); i++ { switch c := code[i]; c { case '"', '\'': var b strings.Builder j := i + 1 for j < len(code) { if c == '"' && code[j] == '\\' && j+1 < len(code) { b.WriteString(code[j : j+2]) j += 2 continue } if code[j] == c { break } b.WriteByte(code[j]) j++ } text := b.String() if c == '"' { text = unescape(text) } values = append(values, text) i = j case '[': depth++ case ']': depth-- if depth == 0 && closeAt < 0 { closeAt = i + 1 } } } return values, depth, closeAt } // splice replaces lines from..to inclusive with the given block. func splice(lines []string, from, to int, block []string) []byte { out := make([]string, 0, len(lines)+len(block)) out = append(out, lines[:from]...) out = append(out, block...) if to < len(lines) { out = append(out, lines[to+1:]...) } return []byte(strings.Join(out, "\n")) } func unescape(s string) string { s = strings.ReplaceAll(s, `\"`, `"`) return strings.ReplaceAll(s, `\\`, `\`) }