Files
convy/internal/doc/doc.go
T
av ccf046fb7b suite check: реализована проверка целостности набора
- разбор документа по языку конвенций: шапка, области правил, блоки под метками
- проверки формы правила, распространения, ссылок и самого suite.toml
- словарь языка живёт в бинаре реестром «версия × естественный язык», темы и
  префиксы берутся только из манифеста
2026-07-27 09:53:07 +03:00

377 lines
12 KiB
Go

// Package doc разбирает файл, записанный языком конвенций: шапку, правила и
// их блоки.
//
// Модель разбора взята прямо из языка. Правило — заголовок вида
// `### <ПРЕФИКС>-<номер>. <название>`; область правила тянется от заголовка до
// следующего заголовка любого уровня. Внутри области текст принадлежит
// последнему открытому блоку: метка блок открывает, и блок длится до следующей
// метки или до конца области. Проза — то, что лежит вне областей правил.
//
// Границу считает разметка, а не суждение о том, где правило кончилось: ровно
// поэтому проверка «модальных слов вне правил нет» вообще реализуема.
package doc
import (
"fmt"
"os"
"regexp"
"strconv"
"strings"
"git.vakhrushev.me/av/convy/internal/lang"
)
// Heading — заголовок любого уровня.
type Heading struct {
Level int
Text string
Line int
}
// BlockKind различает блок нормы и блок под меткой.
type BlockKind int
const (
// Norm — блок нормы: открыт модальным словом.
Norm BlockKind = iota + 1
// Marked — блок под меткой: ПОЧЕМУ, ПРИМЕРЫ, СНЯТО, МЕХАНИЗИРОВАНО.
Marked
)
// Block — часть правила, открытая словом словаря в начале абзаца.
type Block struct {
Kind BlockKind
Word string
Level lang.Level
Mark lang.Mark
// Start — строка, на которой стоит открывающая метка.
Start int
// End — последняя строка блока: блок длится до следующей метки или до
// конца области правила.
End int
// Rest — текст абзаца после метки.
Rest string
}
// Rule — правило: заголовок с идентификатором и его область.
type Rule struct {
Prefix string
Num int
Title string
// Line — строка заголовка.
Line int
// HeadingLevel — уровень заголовка; каноническая форма — третий.
HeadingLevel int
// Malformed — заголовок опознан как правило, но записан не по форме
// `### <ПРЕФИКС>-<номер>. <название>`.
Malformed string
// Start, End — область правила: от строки после заголовка до строки
// перед следующим заголовком включительно.
Start, End int
Blocks []Block
}
// ID возвращает идентификатор правила.
func (r Rule) ID() string {
return fmt.Sprintf("%s-%d", r.Prefix, r.Num)
}
// Block ищет первый блок под указанной меткой.
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 перечисляет блоки нормы. Их должно быть ровно ноль (у снятого
// правила) или один: норма — одна фраза, две нормы под одним номером нечем
// адресовать по отдельности.
func (r Rule) Norms() []Block {
var out []Block
for _, b := range r.Blocks {
if b.Kind == Norm {
out = append(out, b)
}
}
return out
}
// Paragraph — абзац: строки между пустыми.
type Paragraph struct {
Start, End int
Lines []string
}
// Text склеивает абзац в одну строку.
func (p Paragraph) Text() string {
return strings.Join(p.Lines, " ")
}
// Document — разобранный файл.
type Document struct {
// Path — путь от корня набора, в форме со слэшами.
Path string
Front Front
lines []string
fence []bool
// Body — первая строка тела, после шапки.
Body int
Headings []Heading
Rules []Rule
}
var (
headingRe = regexp.MustCompile(`^(#{1,6})\s+(.*)$`)
// ruleHeadRe ловит заголовок, начинающийся с идентификатора правила, —
// в том числе записанный не по форме: иначе опечатка в заголовке
// превратила бы правило в прозу и молча исчезла из нумерации.
ruleHeadRe = regexp.MustCompile(`^([A-Z]{4})-(\d+)(.*)$`)
fenceRe = regexp.MustCompile("^\\s*(`{3,}|~{3,})")
)
// Load читает и разбирает файл. path — путь от корня набора, name — путь в
// файловой системе.
func Load(path, name string) (*Document, error) {
data, err := os.ReadFile(name)
if err != nil {
return nil, err
}
return Parse(path, string(data))
}
// Parse разбирает содержимое файла.
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: шапка: %w", path, err)
}
d.markFences()
d.collectHeadings()
d.collectRules()
return d, nil
}
// markFences отмечает строки внутри огороженных блоков кода. Всё, что
// проверяется разбором текста, эти строки пропускает: пример на SQL с
// заглавным WHEN не делает набор двуязычным, а `### XKEY-5` из примера в
// документации — не правило.
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.lineCount(),
}
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 = "у заголовка правила нет названия"
case strings.HasPrefix(tail, "."):
rule.Title = strings.TrimSpace(tail[1:])
default:
rule.Malformed = "после идентификатора в заголовке нет точки"
rule.Title = strings.TrimSpace(tail)
}
d.Rules = append(d.Rules, rule)
}
}
// Blocks размечает области правил по словарю набора. Разметка отложена до
// загрузки манифеста: до неё неизвестно, каким словарём записан набор.
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 выделяет содержимое первого полужирного участка, если абзац с него
// начинается. Метка стоит первой в своём абзаце, полужирным и с точкой —
// именно этим она отличается от упоминания ступени в середине фразы.
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 режет диапазон строк на абзацы. Границы включительные, нумерация
// с единицы. Строки внутри огороженных блоков в абзацы не попадают: код —
// иллюстрация, а не текст правила.
func (d *Document) Paragraphs(from, to int) []Paragraph {
var out []Paragraph
var cur *Paragraph
for n := max(from, 1); n <= min(to, d.lineCount()); 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 возвращает границы вводной прозы: от тела до первого правила.
func (d *Document) Preamble() (from, to int) {
if len(d.Rules) == 0 {
return d.Body, d.lineCount()
}
return d.Body, d.Rules[0].Line - 1
}
// InRule отвечает, лежит ли строка внутри области какого-нибудь правила.
func (d *Document) InRule(n int) bool {
for _, r := range d.Rules {
if n >= r.Line && n <= r.End {
return true
}
}
return false
}
// Line возвращает строку с номером n.
func (d *Document) Line(n int) string {
if n < 1 || n > d.lineCount() {
return ""
}
return d.lines[n-1]
}
// Fenced отвечает, лежит ли строка внутри огороженного блока кода.
func (d *Document) Fenced(n int) bool {
return n >= 1 && n <= d.lineCount() && d.fence[n-1]
}
// Prose проходит строки тела, не попавшие в огороженные блоки, и отдаёт их с
// вырезанным содержимым инлайн-кода. В бэктиках идентификатор стоит как
// пример записи, а не как ссылка, — различает их именно разметка.
func (d *Document) Prose(yield func(n int, text string) bool) {
for n := d.Body; n <= d.lineCount(); n++ {
if d.fence[n-1] {
continue
}
if !yield(n, StripInline(d.lines[n-1])) {
return
}
}
}
// StripInline вырезает содержимое инлайн-кода, оставляя разделители.
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 возвращает число строк в файле.
func (d *Document) Len() int { return len(d.lines) }
func (d *Document) lineCount() int { return len(d.lines) }