// 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 boundary between what the suite wrote and what the // repository wrote. It is defined once, next to the parsing that has to respect // it, and named again here because assembly is where it is placed. const LocalMarker = doc.LocalMarker // 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) } local := LocalMarker + "\n" existing, err := os.ReadFile(name) switch { case os.IsNotExist(err): made.Created = true case err != nil: return Copy{}, err default: kept, err := ours(rel, string(existing), topic) if err != nil { return Copy{}, err } local = kept } 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 decides whether a file standing in the way may be rewritten, and returns // the part of it that survives: everything from the marker down. // // Three ways it may not. A file whose origin key was taken away has stopped // being a copy and become a document of the repository. A file carrying the // origin of another topic is another copy that would be buried by this one. And // a file with no marker cannot be rewritten either — the marker is always // placed by the assembler, so a copy without one was edited by hand, and // everything in it counts as suite text that assembly would silently replace. func ours(rel, existing, topic string) (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) } marker := d.Marker() if marker == 0 { return "", fmt.Errorf("%s carries no %s marker, and the assembler always leaves one: whatever is in the file was written above the boundary and would be replaced without trace", rel, LocalMarker) } return strings.TrimRight(d.Below(marker), "\n") + "\n", nil } // 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 }