проектные команды и ссылки на источник
- заведён internal/source: уровни ссылаются друг на друга путём на диске или git-репозиторием, ревизия закрепляется хвостом #ref; клон делается заново и удаляется, кэша нет - добавлены init, add, pull, list, check в проекте — манифест .conventions.toml, сборка копий по разу на компонент, маркер локальной части, READING.md рядом - проверки формы развязаны с набором: принимают lang.Vocabulary, а язык копии узнаётся по строке о версии — манифеста рядом с ней нет
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
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, `\\`, `\`)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package manifest_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/convy/internal/manifest"
|
||||
)
|
||||
|
||||
const projectSource = `# What this repository takes.
|
||||
source = "../conventions"
|
||||
|
||||
# A component is a region where every chosen layer holds at once.
|
||||
[components.backend]
|
||||
dir = "backend/docs/conventions"
|
||||
lang = ["go"]
|
||||
topics = ["errors", "time"]
|
||||
|
||||
[components.web]
|
||||
dir = "web/docs/conventions"
|
||||
topics = [
|
||||
"client-logging",
|
||||
]
|
||||
`
|
||||
|
||||
func TestAddToListKeepsTheShapeOfTheArray(t *testing.T) {
|
||||
out, err := manifest.AddToList([]byte(projectSource), "components.backend", "topics", "logging")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := string(out)
|
||||
if !strings.Contains(body, `topics = ["errors", "logging", "time"]`) {
|
||||
t.Errorf("a sorted one-line array did not stay sorted and on one line:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "# A component is a region where every chosen layer holds at once.") {
|
||||
t.Errorf("the comment did not survive the edit:\n%s", body)
|
||||
}
|
||||
|
||||
out, err = manifest.AddToList(out, "components.web", "topics", "auth")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "topics = [\n \"auth\",\n \"client-logging\",\n]"
|
||||
if !strings.Contains(string(out), want) {
|
||||
t.Errorf("an array written down a column did not stay a column:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddToListCreatesTheKeyItDoesNotFind(t *testing.T) {
|
||||
out, err := manifest.AddToList([]byte(projectSource), "components.web", "stack", "express")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(out), `stack = ["express"]`) {
|
||||
t.Errorf("the missing key was not created:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddToListRefusesWhatIsThereAlready(t *testing.T) {
|
||||
_, err := manifest.AddToList([]byte(projectSource), "components.backend", "topics", "time")
|
||||
if err == nil || !strings.Contains(err.Error(), "already holds") {
|
||||
t.Errorf("a repeated subscription went through: %v", err)
|
||||
}
|
||||
if _, err := manifest.AddToList([]byte(projectSource), "components.mobile", "topics", "time"); err == nil {
|
||||
t.Errorf("a table that is not there took an entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveFromList(t *testing.T) {
|
||||
out, err := manifest.RemoveFromList([]byte(projectSource), "components.backend", "topics", "errors")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(out), `topics = ["time"]`) {
|
||||
t.Errorf("the value was not removed:\n%s", out)
|
||||
}
|
||||
if _, err := manifest.RemoveFromList([]byte(projectSource), "components.backend", "topics", "logging"); err == nil {
|
||||
t.Errorf("removing what is not there went through")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddTableGoesLast(t *testing.T) {
|
||||
out, err := manifest.AddTable([]byte(projectSource), "components.mobile",
|
||||
[][2]string{{"dir", `"mobile/docs/conventions"`}, {"topics", "[]"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := string(out)
|
||||
if !strings.Contains(body, "[components.mobile]\ndir = \"mobile/docs/conventions\"\ntopics = []") {
|
||||
t.Errorf("the table was not written:\n%s", body)
|
||||
}
|
||||
if strings.Index(body, "[components.mobile]") < strings.Index(body, "[components.web]") {
|
||||
t.Errorf("the new table did not go last:\n%s", body)
|
||||
}
|
||||
if _, err := manifest.AddTable(out, "components.mobile", nil); err == nil {
|
||||
t.Errorf("a second table of the same name went through")
|
||||
}
|
||||
}
|
||||
@@ -27,12 +27,19 @@ const Name = "suite.toml"
|
||||
// binary, and making every suite declare what is already implied buys nothing.
|
||||
const DefaultLanguageCode = "ru"
|
||||
|
||||
// Language is the [language] section: the version of the conventions language
|
||||
// and the two documents about it. The full description stays with the author of
|
||||
// the suite, the short one travels into the copy.
|
||||
// Language is the [language] section: the version of the conventions language,
|
||||
// the natural language its words are written in, and the two documents about
|
||||
// it. The full description stays with the author of the suite, the short one
|
||||
// travels into the copy.
|
||||
//
|
||||
// Source is where those two documents live. Empty means the suite itself, which
|
||||
// is where they lie while the specification of the language has no repository
|
||||
// of its own; once it moves out, the same key names it without anything else
|
||||
// changing — the vocabulary is picked by version and code either way.
|
||||
type Language struct {
|
||||
Version int `toml:"version"`
|
||||
Lang string `toml:"lang"`
|
||||
Source string `toml:"source"`
|
||||
Description string `toml:"description"`
|
||||
Reading string `toml:"reading"`
|
||||
}
|
||||
@@ -87,12 +94,17 @@ func Load(root string) (*Manifest, error) {
|
||||
// Find walks up from start looking for a directory that holds a suite
|
||||
// manifest, so that `convy suite check` works from any subdirectory of a suite.
|
||||
func Find(start string) (string, error) {
|
||||
return findUp(start, Name)
|
||||
}
|
||||
|
||||
// findUp walks up from start looking for a directory holding the named file.
|
||||
func findUp(start, name string) (string, error) {
|
||||
dir, err := filepath.Abs(start)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(dir, Name)); err == nil {
|
||||
if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
|
||||
return dir, nil
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
// ProjectName is the name of the manifest of a consuming repository. Which of
|
||||
// the two manifests lies next to you tells you where you are, so the project
|
||||
// one is never named like the suite one.
|
||||
const ProjectName = ".conventions.toml"
|
||||
|
||||
// Component is one addressee of assembly inside a project: a region where every
|
||||
// selected layer holds at once — one language, one set of tools, one kind of
|
||||
// application (META-36).
|
||||
//
|
||||
// Lang and Stack are lists because a component may well take two stack layers
|
||||
// at a time — sqlite and postgres in the schema topic hold together, being
|
||||
// different tables of one service. Two languages never do: a line of code is
|
||||
// written in one of them, which is what a component exists to separate.
|
||||
type Component struct {
|
||||
Dir string `toml:"dir"`
|
||||
Lang []string `toml:"lang"`
|
||||
Stack []string `toml:"stack"`
|
||||
Topics []string `toml:"topics"`
|
||||
}
|
||||
|
||||
// Project is a parsed .conventions.toml: where the copies are taken from and
|
||||
// which components take what.
|
||||
type Project struct {
|
||||
// Source is the reference to the suite. How the tool reaches it — a path
|
||||
// on disk, a git repository — is a matter of the reference itself.
|
||||
Source string `toml:"source"`
|
||||
Components map[string]Component `toml:"components"`
|
||||
|
||||
// Path is where the manifest was read from.
|
||||
Path string `toml:"-"`
|
||||
// Root is the directory the manifest lies in; every path in it is relative
|
||||
// to that directory.
|
||||
Root string `toml:"-"`
|
||||
// Undecoded lists keys the tool does not know: a typo in a component name
|
||||
// or in a key would otherwise cost a whole subscription in silence.
|
||||
Undecoded []string `toml:"-"`
|
||||
}
|
||||
|
||||
// LoadProject reads the project manifest from directory root.
|
||||
func LoadProject(root string) (*Project, error) {
|
||||
path := filepath.Join(root, ProjectName)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading the project manifest: %w", err)
|
||||
}
|
||||
|
||||
var p Project
|
||||
meta, err := toml.Decode(string(data), &p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing %s: %w", path, err)
|
||||
}
|
||||
p.Path = path
|
||||
p.Root = root
|
||||
for _, key := range meta.Undecoded() {
|
||||
p.Undecoded = append(p.Undecoded, key.String())
|
||||
}
|
||||
sort.Strings(p.Undecoded)
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// FindProject walks up from start looking for a project manifest, so that a
|
||||
// command works from any subdirectory of a repository.
|
||||
func FindProject(start string) (string, error) {
|
||||
return findUp(start, ProjectName)
|
||||
}
|
||||
|
||||
// Names lists the components in an order stable between runs.
|
||||
func (p *Project) Names() []string {
|
||||
names := make([]string, 0, len(p.Components))
|
||||
for name := range p.Components {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// Only picks the component a command works on. With a name given it is that
|
||||
// one; without a name it is the single component of the project. A project of
|
||||
// several components does not get one guessed for it — it gets the list.
|
||||
func (p *Project) Only(name string) (string, Component, error) {
|
||||
if name != "" {
|
||||
c, ok := p.Components[name]
|
||||
if !ok {
|
||||
return "", Component{}, fmt.Errorf("the project declares no component %q; it declares: %s", name, joinNames(p.Names()))
|
||||
}
|
||||
return name, c, nil
|
||||
}
|
||||
switch len(p.Components) {
|
||||
case 0:
|
||||
return "", Component{}, fmt.Errorf("%s declares no component, and a copy is assembled for a component", p.Path)
|
||||
case 1:
|
||||
only := p.Names()[0]
|
||||
return only, p.Components[only], nil
|
||||
}
|
||||
return "", Component{}, fmt.Errorf("the project holds several components, and the command names none: pass --for with one of %s", joinNames(p.Names()))
|
||||
}
|
||||
|
||||
// Subscribed reports whether a component takes a topic.
|
||||
func (c Component) Subscribed(topic string) bool {
|
||||
return slices.Contains(c.Topics, topic)
|
||||
}
|
||||
|
||||
func joinNames(names []string) string {
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
Reference in New Issue
Block a user