комментарии и сообщения переведены на английский

- комментарии, тексты ошибок, вывод CLI и сообщения тестов теперь на английском
- по-русски остались только литералы словаря ru и содержимое фикстур: это
  данные под проверкой, а не текст инструмента
- согласование числительных в итоге упростилось до английского plural
This commit is contained in:
av
2026-07-27 10:04:27 +03:00
parent 0b8cc125b3
commit b2d07ae55d
17 changed files with 548 additions and 524 deletions
+77 -73
View File
@@ -1,14 +1,15 @@
// Package doc разбирает файл, записанный языком конвенций: шапку, правила и
// их блоки.
// 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 (
@@ -21,62 +22,64 @@ import (
"git.vakhrushev.me/av/convy/internal/lang"
)
// Heading — заголовок любого уровня.
// Heading is a heading of any level.
type Heading struct {
Level int
Text string
Line int
}
// BlockKind различает блок нормы и блок под меткой.
// BlockKind tells a norm block from a marked one.
type BlockKind int
const (
// Norm — блок нормы: открыт модальным словом.
// Norm is a block of the norm, opened by a modal word.
Norm BlockKind = iota + 1
// Marked — блок под меткой: ПОЧЕМУ, ПРИМЕРЫ, СНЯТО, МЕХАНИЗИРОВАНО.
// Marked is a block under a mark: rationale, examples, retired,
// mechanized.
Marked
)
// Block — часть правила, открытая словом словаря в начале абзаца.
// 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 — строка, на которой стоит открывающая метка.
// Start is the line the opening mark stands on.
Start int
// End — последняя строка блока: блок длится до следующей метки или до
// конца области правила.
// 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 — текст абзаца после метки.
// Rest is the text of the paragraph after the mark.
Rest string
}
// Rule — правило: заголовок с идентификатором и его область.
// Rule is a rule: a heading carrying an identifier, plus its area.
type Rule struct {
Prefix string
Num int
Title string
// Line — строка заголовка.
// Line is the line of the heading.
Line int
// HeadingLevel — уровень заголовка; каноническая форма — третий.
// HeadingLevel is the level of the heading; the canonical form is three.
HeadingLevel int
// Malformed — заголовок опознан как правило, но записан не по форме
// `### <ПРЕФИКС>-<номер>. <название>`.
// Malformed is set when a heading was recognized as a rule but is not
// written in the form `### <PREFIX>-<number>. <title>`.
Malformed string
// Start, End — область правила: от строки после заголовка до строки
// перед следующим заголовком включительно.
// 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 возвращает идентификатор правила.
// ID returns the identifier of the rule.
func (r Rule) ID() string {
return fmt.Sprintf("%s-%d", r.Prefix, r.Num)
}
// Block ищет первый блок под указанной меткой.
// 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 {
@@ -86,9 +89,9 @@ func (r Rule) Block(m lang.Mark) (Block, bool) {
return Block{}, false
}
// Norms перечисляет блоки нормы. Их должно быть ровно ноль (у снятого
// правила) или один: норма — одна фраза, две нормы под одним номером нечем
// адресовать по отдельности.
// 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 {
@@ -99,25 +102,25 @@ func (r Rule) Norms() []Block {
return out
}
// Paragraph — абзац: строки между пустыми.
// Paragraph is a paragraph: the lines between blank ones.
type Paragraph struct {
Start, End int
Lines []string
}
// Text склеивает абзац в одну строку.
// Text joins the paragraph into a single string.
func (p Paragraph) Text() string {
return strings.Join(p.Lines, " ")
}
// Document — разобранный файл.
// Document is a parsed file.
type Document struct {
// Path — путь от корня набора, в форме со слэшами.
// Path is the path from the root of the suite, in slash form.
Path string
Front Front
lines []string
fence []bool
// Body — первая строка тела, после шапки.
// Body is the first line of the body, past the front matter.
Body int
Headings []Heading
Rules []Rule
@@ -125,15 +128,15 @@ type Document struct {
var (
headingRe = regexp.MustCompile(`^(#{1,6})\s+(.*)$`)
// ruleHeadRe ловит заголовок, начинающийся с идентификатора правила,
// в том числе записанный не по форме: иначе опечатка в заголовке
// превратила бы правило в прозу и молча исчезла из нумерации.
// 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 читает и разбирает файл. path — путь от корня набора, name — путь в
// файловой системе.
// 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 {
@@ -142,7 +145,7 @@ func Load(path, name string) (*Document, error) {
return Parse(path, string(data))
}
// Parse разбирает содержимое файла.
// 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")
@@ -151,7 +154,7 @@ func Parse(path, content string) (*Document, error) {
d.Front = front
d.Body = body
if err != nil {
return d, fmt.Errorf("%s: шапка: %w", path, err)
return d, fmt.Errorf("%s: front matter: %w", path, err)
}
d.markFences()
@@ -160,10 +163,10 @@ func Parse(path, content string) (*Document, error) {
return d, nil
}
// markFences отмечает строки внутри огороженных блоков кода. Всё, что
// проверяется разбором текста, эти строки пропускает: пример на SQL с
// заглавным WHEN не делает набор двуязычным, а `### XKEY-5` из примера в
// документации — не правило.
// 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 := ""
@@ -217,7 +220,7 @@ func (d *Document) collectRules() {
Line: h.Line,
HeadingLevel: h.Level,
Start: h.Line + 1,
End: d.lineCount(),
End: d.Len(),
}
if i+1 < len(d.Headings) {
rule.End = d.Headings[i+1].Line - 1
@@ -228,19 +231,20 @@ func (d *Document) collectRules() {
case strings.HasPrefix(tail, ". "):
rule.Title = strings.TrimSpace(tail[2:])
case tail == "":
rule.Malformed = "у заголовка правила нет названия"
rule.Malformed = "the rule heading has no title"
case strings.HasPrefix(tail, "."):
rule.Title = strings.TrimSpace(tail[1:])
default:
rule.Malformed = "после идентификатора в заголовке нет точки"
rule.Malformed = "no period after the identifier in the heading"
rule.Title = strings.TrimSpace(tail)
}
d.Rules = append(d.Rules, rule)
}
}
// Blocks размечает области правил по словарю набора. Разметка отложена до
// загрузки манифеста: до неё неизвестно, каким словарём записан набор.
// 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]
@@ -269,9 +273,9 @@ func (d *Document) Blocks(v lang.Vocabulary) {
}
}
// boldLead выделяет содержимое первого полужирного участка, если абзац с него
// начинается. Метка стоит первой в своём абзаце, полужирным и с точкой
// именно этим она отличается от упоминания ступени в середине фразы.
// 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, "**") {
@@ -284,13 +288,13 @@ func boldLead(line string) (bold, rest string, ok bool) {
return line[2 : 2+end], line[2+end+2:], true
}
// Paragraphs режет диапазон строк на абзацы. Границы включительные, нумерация
// с единицы. Строки внутри огороженных блоков в абзацы не попадают: код —
// иллюстрация, а не текст правила.
// 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.lineCount()); n++ {
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
@@ -306,15 +310,16 @@ func (d *Document) Paragraphs(from, to int) []Paragraph {
return out
}
// Preamble возвращает границы вводной прозы: от тела до первого правила.
// 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.lineCount()
return d.Body, d.Len()
}
return d.Body, d.Rules[0].Line - 1
}
// InRule отвечает, лежит ли строка внутри области какого-нибудь правила.
// 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 {
@@ -324,24 +329,25 @@ func (d *Document) InRule(n int) bool {
return false
}
// Line возвращает строку с номером n.
// Line returns line number n.
func (d *Document) Line(n int) string {
if n < 1 || n > d.lineCount() {
if n < 1 || n > d.Len() {
return ""
}
return d.lines[n-1]
}
// Fenced отвечает, лежит ли строка внутри огороженного блока кода.
// Fenced reports whether a line lies inside a fenced code block.
func (d *Document) Fenced(n int) bool {
return n >= 1 && n <= d.lineCount() && d.fence[n-1]
return n >= 1 && n <= d.Len() && d.fence[n-1]
}
// Prose проходит строки тела, не попавшие в огороженные блоки, и отдаёт их с
// вырезанным содержимым инлайн-кода. В бэктиках идентификатор стоит как
// пример записи, а не как ссылка, — различает их именно разметка.
// 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.lineCount(); n++ {
for n := d.Body; n <= d.Len(); n++ {
if d.fence[n-1] {
continue
}
@@ -351,7 +357,7 @@ func (d *Document) Prose(yield func(n int, text string) bool) {
}
}
// StripInline вырезает содержимое инлайн-кода, оставляя разделители.
// StripInline cuts out the contents of inline code, keeping the delimiters.
func StripInline(line string) string {
var b strings.Builder
inCode := false
@@ -370,7 +376,5 @@ func StripInline(line string) string {
return b.String()
}
// Len возвращает число строк в файле.
// Len returns the number of lines in the file.
func (d *Document) Len() int { return len(d.lines) }
func (d *Document) lineCount() int { return len(d.lines) }