package manifest import ( "fmt" "regexp" "slices" "sort" "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. // // The shape of the array survives too. An array written on one line stays on // one line, one written down a column stays a column: reflowing it would make // every commit that adds a topic look like a rewrite of the file. var arrayKeyRe = regexp.MustCompile(`^(\s*)("[^"]+"|[A-Za-z0-9_-]+)\s*=\s*\[`) // AddToList appends 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, to, found := arrayBounds(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 } values := arrayValues(lines[from : to+1]) if slices.Contains(values, value) { return nil, fmt.Errorf("%s already holds %q", key, value) } sorted := sort.StringsAreSorted(values) values = append(values, value) if sorted { sort.Strings(values) } indent := arrayKeyRe.FindStringSubmatch(lines[from])[1] return splice(lines, from, to, renderArray(indent, key, values, from != to)), nil } // RemoveFromList drops a value from the array under key. 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, to, found := arrayBounds(lines, start+1, end, key) if !found { return nil, fmt.Errorf("the table [%s] holds no key %s", table, key) } values := arrayValues(lines[from : to+1]) i := slices.Index(values, value) if i < 0 { return nil, fmt.Errorf("%s does not hold %q", key, value) } values = slices.Delete(values, i, i+1) indent := arrayKeyRe.FindStringSubmatch(lines[from])[1] return splice(lines, from, to, renderArray(indent, key, values, from != to)), nil } // 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 } // arrayBounds finds the first and the last line of the array under key. func arrayBounds(lines []string, from, to int, key string) (start, end int, ok bool) { for i := from; i < to; i++ { m := arrayKeyRe.FindStringSubmatch(lines[i]) if m == nil || strings.Trim(m[2], `"`) != key { continue } for j := i; j < to; j++ { if strings.Contains(lines[j], "]") { return i, j, true } } return i, i, true } return 0, 0, false } var stringRe = regexp.MustCompile(`"((?:[^"\\]|\\.)*)"`) // arrayValues pulls the strings out of an array. The array holds names — of // topics, of languages, of stacks — and a name is a string; anything else in // there is not a thing this tool wrote. func arrayValues(lines []string) []string { text := strings.Join(lines, " ") if i := strings.Index(text, "["); i >= 0 { text = text[i:] } var out []string for _, m := range stringRe.FindAllStringSubmatch(text, -1) { out = append(out, unescape(m[1])) } return out } // renderArray writes the array back in the shape it had. func renderArray(indent, key string, values []string, column bool) []string { quoted := make([]string, len(values)) for i, v := range values { quoted[i] = `"` + escape(v) + `"` } if !column { return []string{fmt.Sprintf("%s%s = [%s]", indent, key, strings.Join(quoted, ", "))} } out := []string{fmt.Sprintf("%s%s = [", indent, key)} for _, q := range quoted { out = append(out, indent+" "+q+",") } return append(out, indent+"]") } // 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, `\\`, `\`) }