- разбор документа по языку конвенций: шапка, области правил, блоки под метками - проверки формы правила, распространения, ссылок и самого suite.toml - словарь языка живёт в бинаре реестром «версия × естественный язык», темы и префиксы берутся только из манифеста
377 lines
12 KiB
Go
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) }
|