// 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) }