Files
av b6b0976c19 исправлены находки ревью проектной стороны
- манифест читается так, как записан: решётка внутри строки не открывает
  комментарий, скобка внутри комментария не закрывает массив, имя внутри
  комментария не становится подпиской; новый ключ встаёт после массива,
  а не внутрь него
- всё записываемое проходит через manifest.Quote — обратный слэш в пути
  делал файл, который инструмент сам не читает
- маркер локальной части переехал в doc и пропускает огороженные блоки:
  процитированный в примере маркер больше не считается границей, а копия
  без маркера не перезаписывается молча
- лишний позиционный аргумент отсекается: flag прекращал разбор и прятал
  флаги после себя, из-за чего pull, list и check игнорировали --for
- заведены тесты проверок копий, включая молчание на исправной копии
2026-07-27 21:16:29 +03:00

424 lines
11 KiB
Go

// Package doc parses a file written in the conventions language: its front
// matter, its rules and their blocks.
//
// The parsing model is taken straight from the language. A rule is a heading of
// the form `### <PREFIX>-<number>. <title>`; the area of a rule runs from that
// heading to the next heading of any level. Inside the area text belongs to the
// last block opened: a mark opens a block, and the block lasts until the next
// mark or until the end of the area. Prose is what lies outside rule areas.
//
// The boundary is counted by the markup rather than by a judgement about where
// a rule ended: that is exactly what makes the "no modal words outside rules"
// check implementable at all.
package doc
import (
"fmt"
"os"
"regexp"
"strconv"
"strings"
"git.vakhrushev.me/av/convy/internal/lang"
)
// Heading is a heading of any level.
type Heading struct {
Level int
Text string
Line int
}
// BlockKind tells a norm block from a marked one.
type BlockKind int
const (
// Norm is a block of the norm, opened by a modal word.
Norm BlockKind = iota + 1
// Marked is a block under a mark: rationale, examples, retired,
// mechanized.
Marked
)
// Block is a part of a rule opened by a vocabulary word at the start of a
// paragraph.
type Block struct {
Kind BlockKind
Word string
Level lang.Level
Mark lang.Mark
// Start is the line the opening mark stands on.
Start int
// End is the last line of the block: a block lasts until the next mark
// or until the end of the rule area.
End int
// Rest is the text of the paragraph after the mark.
Rest string
}
// Rule is a rule: a heading carrying an identifier, plus its area.
type Rule struct {
Prefix string
Num int
Title string
// Line is the line of the heading.
Line int
// HeadingLevel is the level of the heading; the canonical form is three.
HeadingLevel int
// Malformed is set when a heading was recognized as a rule but is not
// written in the form `### <PREFIX>-<number>. <title>`.
Malformed string
// Start and End bound the rule area: from the line after the heading to
// the line before the next heading, inclusive.
Start, End int
Blocks []Block
}
// ID returns the identifier of the rule.
func (r Rule) ID() string {
return fmt.Sprintf("%s-%d", r.Prefix, r.Num)
}
// Block finds the first block under the given mark.
func (r Rule) Block(m lang.Mark) (Block, bool) {
for _, b := range r.Blocks {
if b.Kind == Marked && b.Mark == m {
return b, true
}
}
return Block{}, false
}
// Norms lists the norm blocks. There must be exactly zero of them (for a
// retired rule) or one: a norm is a single statement, and two norms under one
// number leave no way to address either on its own.
func (r Rule) Norms() []Block {
var out []Block
for _, b := range r.Blocks {
if b.Kind == Norm {
out = append(out, b)
}
}
return out
}
// Paragraph is a paragraph: the lines between blank ones.
type Paragraph struct {
Start, End int
Lines []string
}
// Text joins the paragraph into a single string.
func (p Paragraph) Text() string {
return strings.Join(p.Lines, " ")
}
// Document is a parsed file.
type Document struct {
// Path is the path from the root of the suite, in slash form.
Path string
Front Front
lines []string
fence []bool
// Body is the first line of the body, past the front matter.
Body int
Headings []Heading
Rules []Rule
}
var (
headingRe = regexp.MustCompile(`^(#{1,6})\s+(.*)$`)
// ruleHeadRe catches a heading that starts with a rule identifier —
// including one written out of form: otherwise a typo in a heading would
// turn a rule into prose and vanish from the numbering unnoticed.
ruleHeadRe = regexp.MustCompile(`^([A-Z]{4})-(\d+)(.*)$`)
fenceRe = regexp.MustCompile("^\\s*(`{3,}|~{3,})")
)
// Load reads and parses a file. path is the path from the root of the suite,
// name the path in the file system.
func Load(path, name string) (*Document, error) {
data, err := os.ReadFile(name)
if err != nil {
return nil, err
}
return Parse(path, string(data))
}
// Parse parses the contents of a file.
func Parse(path, content string) (*Document, error) {
d := &Document{Path: path}
d.lines = strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
front, body, err := parseFront(d.lines)
d.Front = front
d.Body = body
if err != nil {
return d, fmt.Errorf("%s: front matter: %w", path, err)
}
d.markFences()
d.collectHeadings()
d.collectRules()
return d, nil
}
// markFences marks the lines inside fenced code blocks. Everything checked by
// parsing text skips them: a SQL sample with an uppercase WHEN does not make
// the suite bilingual, and a `### XKEY-5` inside a documentation sample is not
// a rule.
func (d *Document) markFences() {
d.fence = make([]bool, len(d.lines))
open := ""
for i, line := range d.lines {
m := fenceRe.FindStringSubmatch(line)
if open == "" {
if m != nil {
open = m[1]
d.fence[i] = true
}
continue
}
d.fence[i] = true
if m != nil && len(m[1]) >= len(open) && m[1][0] == open[0] {
open = ""
}
}
}
func (d *Document) collectHeadings() {
for i, line := range d.lines {
num := i + 1
if num < d.Body || d.fence[i] {
continue
}
m := headingRe.FindStringSubmatch(line)
if m == nil {
continue
}
d.Headings = append(d.Headings, Heading{
Level: len(m[1]),
Text: strings.TrimSpace(m[2]),
Line: num,
})
}
}
func (d *Document) collectRules() {
for i, h := range d.Headings {
m := ruleHeadRe.FindStringSubmatch(h.Text)
if m == nil {
continue
}
num, err := strconv.Atoi(m[2])
if err != nil {
continue
}
rule := Rule{
Prefix: m[1],
Num: num,
Line: h.Line,
HeadingLevel: h.Level,
Start: h.Line + 1,
End: d.Len(),
}
if i+1 < len(d.Headings) {
rule.End = d.Headings[i+1].Line - 1
}
tail := m[3]
switch {
case strings.HasPrefix(tail, ". "):
rule.Title = strings.TrimSpace(tail[2:])
case tail == "":
rule.Malformed = "the rule heading has no title"
case strings.HasPrefix(tail, "."):
rule.Title = strings.TrimSpace(tail[1:])
default:
rule.Malformed = "no period after the identifier in the heading"
rule.Title = strings.TrimSpace(tail)
}
d.Rules = append(d.Rules, rule)
}
}
// Blocks marks up the rule areas using the suite's vocabulary. The markup is
// deferred until the manifest is loaded: before that it is unknown which
// vocabulary the suite is written in.
func (d *Document) Blocks(v lang.Vocabulary) {
for i := range d.Rules {
r := &d.Rules[i]
r.Blocks = nil
for _, p := range d.Paragraphs(r.Start, r.End) {
word, rest, ok := boldLead(p.Lines[0])
if !ok {
continue
}
w, ok := v.Lead(word)
if !ok {
continue
}
b := Block{Word: w, Start: p.Start, End: r.End, Rest: strings.TrimSpace(rest)}
if level, ok := v.Modal(w); ok {
b.Kind, b.Level = Norm, level
} else {
mark, _ := v.Mark(w)
b.Kind, b.Mark = Marked, mark
}
if n := len(r.Blocks); n > 0 {
r.Blocks[n-1].End = p.Start - 1
}
r.Blocks = append(r.Blocks, b)
}
}
}
// boldLead extracts the contents of the first bold span if the paragraph opens
// with one. A mark stands first in its paragraph, in bold and with a period —
// that is exactly what tells it from a mention of a step mid-sentence.
func boldLead(line string) (bold, rest string, ok bool) {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "**") {
return "", "", false
}
end := strings.Index(line[2:], "**")
if end < 0 {
return "", "", false
}
return line[2 : 2+end], line[2+end+2:], true
}
// Paragraphs cuts a range of lines into paragraphs. Bounds are inclusive and
// numbering starts at one. Lines inside fenced blocks do not enter paragraphs:
// code is an illustration, not the text of a rule.
func (d *Document) Paragraphs(from, to int) []Paragraph {
var out []Paragraph
var cur *Paragraph
for n := max(from, 1); n <= min(to, d.Len()); n++ {
line := d.lines[n-1]
if d.fence[n-1] || strings.TrimSpace(line) == "" {
cur = nil
continue
}
if cur == nil {
out = append(out, Paragraph{Start: n, End: n})
cur = &out[len(out)-1]
}
cur.Lines = append(cur.Lines, line)
cur.End = n
}
return out
}
// Preamble returns the bounds of the introductory prose: from the body to the
// first rule.
func (d *Document) Preamble() (from, to int) {
if len(d.Rules) == 0 {
return d.Body, d.Len()
}
return d.Body, d.Rules[0].Line - 1
}
// InRule reports whether a line lies inside the area of some rule.
func (d *Document) InRule(n int) bool {
for _, r := range d.Rules {
if n >= r.Line && n <= r.End {
return true
}
}
return false
}
// Line returns line number n.
func (d *Document) Line(n int) string {
if n < 1 || n > d.Len() {
return ""
}
return d.lines[n-1]
}
// Fenced reports whether a line lies inside a fenced code block.
func (d *Document) Fenced(n int) bool {
return n >= 1 && n <= d.Len() && d.fence[n-1]
}
// Prose walks the lines of the body that did not land in fenced blocks and
// hands them over with the contents of inline code cut out. Inside backticks an
// identifier stands as a sample of the notation rather than as a reference —
// and it is the markup that tells the two apart.
func (d *Document) Prose(yield func(n int, text string) bool) {
for n := d.Body; n <= d.Len(); n++ {
if d.fence[n-1] {
continue
}
if !yield(n, StripInline(d.lines[n-1])) {
return
}
}
}
// StripInline cuts out the contents of inline code, keeping the delimiters.
func StripInline(line string) string {
var b strings.Builder
inCode := false
for _, r := range line {
if r == '`' {
inCode = !inCode
b.WriteRune(' ')
continue
}
if inCode {
b.WriteRune(' ')
continue
}
b.WriteRune(r)
}
return b.String()
}
// Len returns the number of lines in the file.
func (d *Document) Len() int { return len(d.lines) }
// LocalMarker is the boundary inside a copy between what the suite wrote and
// what the consuming repository wrote. It is the one piece of markup inside a
// convention that means something to the tool, and it is single and nameless,
// so there is no name to be orphaned by a rename.
//
// It lives here, next to the parsing, because both halves of the tool need it
// and need it read the same way: the assembler to know what to keep, the check
// to know what answers to which rules. Two constants would drift in silence.
const LocalMarker = "<!-- conv:local -->"
// Marker returns the line the local marker stands on, or zero.
//
// A marker inside a fenced block is a quotation of the markup rather than the
// markup itself — a convention about keeping copies would carry one — and
// taking it for the boundary would hand the whole document to the repository.
func (d *Document) Marker() int {
for n := 1; n <= d.Len(); n++ {
if !d.fence[n-1] && strings.TrimSpace(d.lines[n-1]) == LocalMarker {
return n
}
}
return 0
}
// Markers counts the local markers outside fenced blocks.
func (d *Document) Markers() []int {
var out []int
for n := 1; n <= d.Len(); n++ {
if !d.fence[n-1] && strings.TrimSpace(d.lines[n-1]) == LocalMarker {
out = append(out, n)
}
}
return out
}
// Below returns the lines from n to the end, joined.
func (d *Document) Below(n int) string {
if n < 1 || n > d.Len() {
return ""
}
return strings.Join(d.lines[n-1:], "\n")
}