проектные команды и ссылки на источник

- заведён internal/source: уровни ссылаются друг на друга путём на диске
  или git-репозиторием, ревизия закрепляется хвостом #ref; клон делается
  заново и удаляется, кэша нет
- добавлены init, add, pull, list, check в проекте — манифест
  .conventions.toml, сборка копий по разу на компонент, маркер локальной
  части, READING.md рядом
- проверки формы развязаны с набором: принимают lang.Vocabulary, а язык
  копии узнаётся по строке о версии — манифеста рядом с ней нет
This commit is contained in:
av
2026-07-27 20:42:18 +03:00
parent 4615de6e86
commit 23d88c4048
27 changed files with 3009 additions and 57 deletions
+189
View File
@@ -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, `\\`, `\`)
}
+98
View File
@@ -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")
}
}
+16 -4
View File
@@ -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)
+118
View File
@@ -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, ", ")
}