проектные команды и ссылки на источник
- заведён internal/source: уровни ссылаются друг на друга путём на диске или git-репозиторием, ревизия закрепляется хвостом #ref; клон делается заново и удаляется, кэша нет - добавлены init, add, pull, list, check в проекте — манифест .conventions.toml, сборка копий по разу на компонент, маркер локальной части, READING.md рядом - проверки формы развязаны с набором: принимают lang.Vocabulary, а язык копии узнаётся по строке о версии — манифеста рядом с ней нет
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
// Package project assembles copies of conventions inside a consuming
|
||||
// repository.
|
||||
//
|
||||
// A copy is flat: one file per topic, the layers of the topic inside it as
|
||||
// sections in the order base, language, stack. The paths of the suite are not
|
||||
// reproduced — whoever checks code against a convention reads one file and does
|
||||
// not gather a topic out of three places.
|
||||
//
|
||||
// Everything below the local marker belongs to the repository and survives
|
||||
// reassembly; everything above it is rewritten. There is no three-way merge and
|
||||
// no report of divergence: after a reassembly the difference is shown by git,
|
||||
// and the decision is taken by a person before the commit.
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.vakhrushev.me/av/convy/internal/doc"
|
||||
"git.vakhrushev.me/av/convy/internal/lang"
|
||||
"git.vakhrushev.me/av/convy/internal/manifest"
|
||||
"git.vakhrushev.me/av/convy/internal/suite"
|
||||
)
|
||||
|
||||
// LocalMarker is the one piece of markup inside a copy that means something to
|
||||
// the tool. It is single and nameless, so there is no name to be orphaned by a
|
||||
// rename.
|
||||
const LocalMarker = "<!-- conv:local -->"
|
||||
|
||||
// Copy is the outcome of assembling one topic for one component.
|
||||
type Copy struct {
|
||||
Topic string
|
||||
// Path is where the file lies, from the root of the project.
|
||||
Path string
|
||||
// Layers lists the paths in the suite the file was built from.
|
||||
Layers []string
|
||||
// Created says the file did not exist before.
|
||||
Created bool
|
||||
// Kept says a local part below the marker was carried over.
|
||||
Kept bool
|
||||
}
|
||||
|
||||
// Axis is what a component asks the suite for.
|
||||
func Axis(c manifest.Component) suite.Component {
|
||||
return suite.Component{Lang: c.Lang, Stack: c.Stack}
|
||||
}
|
||||
|
||||
// Assemble builds one topic for one component and writes the file. root is the
|
||||
// root of the project.
|
||||
func Assemble(s *suite.Suite, root string, c manifest.Component, topic string) (Copy, error) {
|
||||
if !s.Manifest.TopicLive(topic) {
|
||||
if s.Manifest.TopicRetired(topic) {
|
||||
return Copy{}, fmt.Errorf("the suite has retired the topic %q: %s", topic, s.Manifest.Topics.Retired[topic])
|
||||
}
|
||||
return Copy{}, fmt.Errorf("the suite declares no live topic %q", topic)
|
||||
}
|
||||
layers, _ := s.Assemble(topic, Axis(c))
|
||||
if len(layers) == 0 {
|
||||
return Copy{}, fmt.Errorf("the topic %q has no layer this component takes", topic)
|
||||
}
|
||||
|
||||
rel := filepath.ToSlash(filepath.Join(c.Dir, topic+".md"))
|
||||
name := filepath.Join(root, filepath.FromSlash(rel))
|
||||
made := Copy{Topic: topic, Path: rel}
|
||||
for _, d := range layers {
|
||||
made.Layers = append(made.Layers, d.Path)
|
||||
}
|
||||
|
||||
existing, err := os.ReadFile(name)
|
||||
switch {
|
||||
case os.IsNotExist(err):
|
||||
made.Created = true
|
||||
case err != nil:
|
||||
return Copy{}, err
|
||||
default:
|
||||
if err := ours(rel, string(existing), topic); err != nil {
|
||||
return Copy{}, err
|
||||
}
|
||||
}
|
||||
|
||||
local := localPart(string(existing))
|
||||
made.Kept = strings.TrimSpace(strings.TrimPrefix(local, LocalMarker)) != ""
|
||||
|
||||
body := Render(topic, layers, s.Vocab) + "\n\n" + local
|
||||
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
|
||||
return Copy{}, err
|
||||
}
|
||||
if err := os.WriteFile(name, []byte(body), 0o644); err != nil {
|
||||
return Copy{}, err
|
||||
}
|
||||
return made, nil
|
||||
}
|
||||
|
||||
// ReadingName is what the reader's guide of the language is called next to the
|
||||
// copies, whatever it is called where it came from.
|
||||
const ReadingName = "READING.md"
|
||||
|
||||
// Reading puts the reader's guide of the language next to the copies. The guide
|
||||
// belongs to the level above and is overwritten whole; README.md standing next
|
||||
// to it belongs to the repository and is never touched.
|
||||
//
|
||||
// The guide travels because the copy names the language by a version and by no
|
||||
// path: without the guide an agent reading a copy takes ДОПУСКАЕТСЯ for the
|
||||
// everyday "you can" and loses exactly what the word was introduced for.
|
||||
func Reading(langRoot, path, root, dir string) (string, error) {
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("the suite manifest names no reading guide, and it is what travels next to the copies")
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(langRoot, filepath.FromSlash(path)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading the guide of the language: %w", err)
|
||||
}
|
||||
rel := filepath.ToSlash(filepath.Join(dir, ReadingName))
|
||||
name := filepath.Join(root, filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(name, body, 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return rel, nil
|
||||
}
|
||||
|
||||
// ours refuses to overwrite a file that is not a copy of this topic. A file
|
||||
// whose origin key was taken away has stopped being a copy and become a
|
||||
// document of the repository, and the tool has no business rewriting it.
|
||||
func ours(rel, existing, topic string) error {
|
||||
d, err := doc.Parse(rel, existing)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s is in the way and cannot be read: %w", rel, err)
|
||||
}
|
||||
switch {
|
||||
case d.Front.Origin == "" && d.Front.Topic == "" && d.Front.Prefix == "":
|
||||
return fmt.Errorf("%s carries no origin key: it is a document of the repository rather than a copy, and assembly would overwrite it", rel)
|
||||
case d.Front.Origin == "":
|
||||
return fmt.Errorf("%s carries no origin key, while it does carry the front matter of a suite file: it looks like a layer put here by hand", rel)
|
||||
case d.Front.Origin != topic:
|
||||
return fmt.Errorf("%s is a copy of the topic %q, and the topic %q would be assembled into the same file", rel, d.Front.Origin, topic)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// localPart returns everything from the marker down, together with the marker.
|
||||
// A file that has none gets one: the marker is placed by the assembler, and a
|
||||
// copy without it has nowhere to put a derogation.
|
||||
func localPart(existing string) string {
|
||||
lines := strings.Split(existing, "\n")
|
||||
for i, line := range lines {
|
||||
if strings.TrimSpace(line) != LocalMarker {
|
||||
continue
|
||||
}
|
||||
return strings.TrimRight(strings.Join(lines[i:], "\n"), "\n") + "\n"
|
||||
}
|
||||
return LocalMarker + "\n"
|
||||
}
|
||||
|
||||
// Render lays out the part of a copy that comes from the suite: the front
|
||||
// matter of the copy and the layers of the topic.
|
||||
func Render(topic string, layers []*doc.Document, v lang.Vocabulary) string {
|
||||
parts := make([]string, 0, len(layers)+1)
|
||||
parts = append(parts, "---\norigin: "+topic+"\n---")
|
||||
for i, d := range layers {
|
||||
parts = append(parts, layerText(d, v, i == 0))
|
||||
}
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
// layerText renders one layer.
|
||||
//
|
||||
// The first layer is the document: its title, its introduction, its sections.
|
||||
// Every layer after it becomes a section of that document, because a layer only
|
||||
// implements and narrows the base rather than standing beside it — so its own
|
||||
// headings step down one level and its title becomes the heading of the
|
||||
// section. The line about the language version is dropped from all but the
|
||||
// first: it says the same thing three times over otherwise, and it is what
|
||||
// makes the copy self-contained rather than decoration to be repeated.
|
||||
func layerText(d *doc.Document, v lang.Vocabulary, first bool) string {
|
||||
skip := map[int]bool{}
|
||||
if !first {
|
||||
if from, to, ok := versionLines(d, v); ok {
|
||||
for n := from; n <= to; n++ {
|
||||
skip[n] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var out []string
|
||||
for n := d.Body; n <= d.Len(); n++ {
|
||||
if skip[n] {
|
||||
continue
|
||||
}
|
||||
line := d.Line(n)
|
||||
if !first && !d.Fenced(n) && strings.HasPrefix(line, "#") {
|
||||
line = "#" + line
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
return strings.Trim(strings.Join(out, "\n"), "\n")
|
||||
}
|
||||
|
||||
// versionLines finds the paragraph carrying the language version line: the one
|
||||
// listing every key word of the vocabulary.
|
||||
func versionLines(d *doc.Document, v lang.Vocabulary) (from, to int, ok bool) {
|
||||
start, end := d.Preamble()
|
||||
words := v.Words()
|
||||
for _, p := range d.Paragraphs(start, end) {
|
||||
text := p.Text()
|
||||
found := true
|
||||
for _, w := range words {
|
||||
if !strings.Contains(text, w) {
|
||||
found = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if found {
|
||||
return p.Start, p.End, true
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package project_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/convy/internal/doc"
|
||||
"git.vakhrushev.me/av/convy/internal/lang"
|
||||
"git.vakhrushev.me/av/convy/internal/project"
|
||||
)
|
||||
|
||||
const versionLine = `Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
|
||||
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
|
||||
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.`
|
||||
|
||||
const baseLayer = `---
|
||||
topic: time
|
||||
prefix: TIME
|
||||
---
|
||||
|
||||
# Время
|
||||
|
||||
Как приложение записывает моменты.
|
||||
|
||||
` + versionLine + `
|
||||
|
||||
## Правила
|
||||
|
||||
### TIME-1. Момент записывается в UTC
|
||||
|
||||
**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.
|
||||
|
||||
**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.
|
||||
`
|
||||
|
||||
const goLayer = `---
|
||||
topic: time
|
||||
prefix: GTIM
|
||||
lang: go
|
||||
extends: arch/time.md
|
||||
---
|
||||
|
||||
# Время: реализация на Go
|
||||
|
||||
Как базовый слой выполняется в Go-коде.
|
||||
|
||||
` + versionLine + `
|
||||
|
||||
## Правила
|
||||
|
||||
### GTIM-1. «Сейчас» берётся у слоя хранилища
|
||||
|
||||
**ДОЛЖЕН.** Текущее время приходит из ` + "`store.Now()`" + `.
|
||||
|
||||
**ПОЧЕМУ.** Единая точка даёт гарантированный UTC.
|
||||
`
|
||||
|
||||
func layers(t *testing.T, bodies ...string) []*doc.Document {
|
||||
t.Helper()
|
||||
v, err := lang.Lookup(1, "ru")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out []*doc.Document
|
||||
for i, body := range bodies {
|
||||
d, err := doc.Parse("layer.md", body)
|
||||
if err != nil {
|
||||
t.Fatalf("layer %d: %v", i, err)
|
||||
}
|
||||
d.Blocks(v)
|
||||
out = append(out, d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func vocab(t *testing.T) lang.Vocabulary {
|
||||
t.Helper()
|
||||
v, err := lang.Lookup(1, "ru")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// A copy is one document rather than three files glued together: the first
|
||||
// layer is the document, and every layer after it is a section of it.
|
||||
func TestRenderMakesTheLayersSectionsOfOneDocument(t *testing.T) {
|
||||
body := project.Render("time", layers(t, baseLayer, goLayer), vocab(t))
|
||||
|
||||
for _, want := range []string{
|
||||
"---\norigin: time\n---",
|
||||
"\n# Время\n",
|
||||
"\n## Правила\n",
|
||||
"\n### TIME-1. Момент записывается в UTC\n",
|
||||
"\n## Время: реализация на Go\n",
|
||||
"\n### Правила\n",
|
||||
"\n#### GTIM-1. «Сейчас» берётся у слоя хранилища\n",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the copy lacks %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "topic: time\nprefix:") {
|
||||
t.Errorf("the front matter of a layer travelled into the copy:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "extends:") {
|
||||
t.Errorf("a path of the suite travelled into the copy:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// The line about the language is what makes a copy readable on its own, and
|
||||
// saying it three times over says nothing more than saying it once.
|
||||
func TestRenderNamesTheLanguageOnce(t *testing.T) {
|
||||
body := project.Render("time", layers(t, baseLayer, goLayer), vocab(t))
|
||||
if n := strings.Count(body, "толкуются как описано"); n != 1 {
|
||||
t.Errorf("the language version line appears %d times:\n%s", n, body)
|
||||
}
|
||||
if !strings.Contains(body, "конвенций версии 1") {
|
||||
t.Errorf("the language version line is gone altogether:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// A single layer is not a special case: assembly out of one layer is a copy of
|
||||
// it, with no section headings invented around it.
|
||||
func TestRenderOfOneLayerKeepsItsLevels(t *testing.T) {
|
||||
body := project.Render("time", layers(t, baseLayer), vocab(t))
|
||||
if !strings.Contains(body, "\n# Время\n") || !strings.Contains(body, "\n### TIME-1.") {
|
||||
t.Errorf("a single layer was restructured:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "####") {
|
||||
t.Errorf("headings of a single layer were demoted:\n%s", body)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user