diff --git a/internal/check/check_test.go b/internal/check/check_test.go index 3b47730..19e8e50 100644 --- a/internal/check/check_test.go +++ b/internal/check/check_test.go @@ -10,8 +10,13 @@ import ( "git.vakhrushev.me/av/convy/internal/suite" ) -// versionLine — строка о версии языка. Она перечисляет ключевые слова набора, -// поэтому единственная законно несёт модальные слова вне правил. +// The fixtures below stay in Russian on purpose: they are the data under test, +// written in the natural language the suite declares. Only the tool's own text +// is English. + +// versionLine is the language version line. It lists the key words of the +// suite, which makes it the only place lawfully carrying modal words outside a +// rule. const versionLine = `Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке конвенций версии 1 — тогда и только тогда, когда написаны заглавными.` @@ -53,8 +58,9 @@ prefix: TIME **ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана. ` -// files — содержимое набора: путь от корня к тексту файла. Пустая строка -// значит «файла нет»: так тест снимает файл, который есть в основе. +// files is the content of a suite: a path from the root mapped to the text of +// the file. An empty string means "no such file": that is how a test drops a +// file the base fixture provides. type files map[string]string func base() files { @@ -66,7 +72,7 @@ func base() files { } } -// run записывает набор во временную директорию и прогоняет проверки. +// run writes a suite into a temporary directory and runs the checks over it. func run(t *testing.T, f files) []check.Finding { t.Helper() root := t.TempDir() @@ -84,7 +90,7 @@ func run(t *testing.T, f files) []check.Finding { } s, err := suite.Load(root) if err != nil { - t.Fatalf("загрузка набора: %v", err) + t.Fatalf("loading the suite: %v", err) } return check.Suite(s).Findings() } @@ -102,25 +108,26 @@ func messages(findings []check.Finding) string { func TestCleanSuite(t *testing.T) { if got := run(t, base()); len(got) != 0 { - t.Fatalf("исправный набор дал находки:\n%s", messages(got)) + t.Fatalf("a sound suite produced findings:\n%s", messages(got)) } } -// TestRuleHeadingIsNotAReference держит границу между объявлением и ссылкой. -// Заголовок с чужим префиксом — ошибка формы, и только она: разрешать заголовок -// по манифесту не к чему, иначе одна опечатка даёт две находки о разном. +// TestRuleHeadingIsNotAReference holds the line between a declaration and a +// reference. A heading with a foreign prefix is an error of form, and only +// that: there is nothing to resolve against the manifest, otherwise one typo +// would yield two findings about different things. func TestRuleHeadingIsNotAReference(t *testing.T) { f := base() f["conventions/time.md"] = strings.Replace(baseTime, "### TIME-1.", "### GTIM-1.", 1) for _, got := range run(t, f) { if got.Family == check.Links { - t.Errorf("заголовок разобран как ссылка: %s", got.Msg) + t.Errorf("a heading was parsed as a reference: %s", got.Msg) } } } -// rule собирает правило целиком, чтобы тесты не повторяли его форму. +// rule builds a whole rule so that tests do not repeat its form. func rule(id, title, norm, rationale string) string { return "\n### " + id + ". " + title + "\n\n**ДОЛЖЕН.** " + norm + "\n\n**ПОЧЕМУ.** " + rationale + "\n" } @@ -131,191 +138,191 @@ func TestChecks(t *testing.T) { setup func(files) want string }{{ - name: "префикс в шапке расходится с манифестом", + name: "front matter prefix diverges from the manifest", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, "prefix: TIME", "prefix: GTIM", 1) }, - want: "префикс в шапке — GTIM, а манифест объявляет за этим файлом TIME", + want: "the front matter declares prefix GTIM, while the manifest assigns TIME to this file", }, { - name: "заголовок правила несёт чужой префикс", + name: "rule heading carries a foreign prefix", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, "### TIME-1.", "### GTIM-1.", 1) }, - want: "использует префикс GTIM, а файлу принадлежит TIME", + want: "the rule heading uses prefix GTIM, while the file owns TIME", }, { - name: "нумерация с дырой", + name: "numbering has a gap", setup: func(f files) { f["conventions/time.md"] = baseTime + rule("TIME-3", "Третье", "Норма.", "Причина.") }, - want: "нумерация не сплошная", + want: "numbering is not contiguous", }, { - name: "номер занят дважды", + name: "a number is taken twice", setup: func(f files) { f["conventions/time.md"] = baseTime + rule("TIME-1", "Ещё раз первое", "Норма.", "Причина.") }, - want: "номер TIME-1 занят дважды", + want: "number TIME-1 is taken twice", }, { - name: "правило без обоснования", + name: "rule without a rationale", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, "**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.", "", 1) }, - want: "нет блока ПОЧЕМУ: обоснование обязательно", + want: "has no ПОЧЕМУ block: the rationale is mandatory", }, { - name: "правило без нормы и без заглушки", + name: "rule with neither a norm nor a stub", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, "**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.", "Просто текст.", 1) }, - want: "нет ни блока нормы, ни заглушки СНЯТО", + want: "has neither a norm block nor a СНЯТО stub", }, { - name: "две нормы под одним номером", + name: "two norms under one number", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, "**ПОЧЕМУ.**", "**СЛЕДУЕТ.** Вторая норма.\n\n**ПОЧЕМУ.**", 1) }, - want: "две нормы (ДОЛЖЕН и СЛЕДУЕТ)", + want: "holds two norms (ДОЛЖЕН and СЛЕДУЕТ)", }, { - name: "обоснование стоит раньше нормы", + name: "rationale precedes the norm", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, "**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.\n\n**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.", "**ПОЧЕМУ.** Причина вперёд.\n\n**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.", 1) }, - want: "обоснование стоит раньше нормы", + want: "the rationale precedes the norm", }, { - name: "примеры стоят раньше обоснования", + name: "examples precede the rationale", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, "**ПОЧЕМУ.**", "**ПРИМЕРЫ.** Иллюстрация.\n\n**ПОЧЕМУ.**", 1) }, - want: "блок ПРИМЕРЫ стоит раньше обоснования", + want: "the ПРИМЕРЫ block precedes the rationale", }, { - name: "заглушка снятого без даты", + name: "stub of a retired rule without a date", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, "**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.\n\n**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.", "**СНЯТО.** Правило убрано за ненадобностью.", 1) }, - want: "не несёт даты снятия", + want: "carries no date of retirement", }, { - name: "у снятого правила осталась норма", + name: "retired rule still holds a norm", setup: func(f files) { f["conventions/time.md"] = baseTime + "\n### TIME-2. Снятое\n\n**СНЯТО 2026-07-26.** Причина снятия.\n\n**ДОЛЖЕН.** Остаток нормы.\n" }, - want: "остался блок нормы", + want: "still holds a norm block", }, { - name: "нет строки о версии языка", + name: "no language version line", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, versionLine, "Просто вводная проза.", 1) }, - want: "нет строки о версии языка", + want: "holds no language version line", }, { - name: "строка о версии называет чужую версию", + name: "version line names a foreign version", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, "конвенций версии 1", "конвенций версии 2", 1) }, - want: "не называет версию 1", + want: "does not name version 1", }, { - name: "модальное слово вне области правила", + name: "modal word outside a rule area", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, "Как приложение записывает моменты.", "Приложение ДОЛЖЕН писать моменты.", 1) }, - want: "стоит вне области правила", + want: "stands outside a rule area", }, { - name: "слово чужого словаря", + name: "word of a foreign vocabulary", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, "**ПОЧЕМУ.** Без явного", "**ПОЧЕМУ.** Здесь MUST не к месту. Без явного", 1) }, - want: `слово MUST принадлежит словарю "en"`, + want: `the word MUST belongs to the "en" vocabulary`, }, { - name: "ссылка на несуществующее правило", + name: "reference to a rule that does not exist", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, "Без явного смещения", "Смотри TIME-9. Без явного смещения", 1) }, - want: "ссылка TIME-9 не разрешается", + want: "reference TIME-9 does not resolve", }, { - name: "ссылка на неизвестный префикс", + name: "reference to an unknown prefix", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, "Без явного смещения", "Смотри ZZZZ-1. Без явного смещения", 1) }, - want: "префикс ZZZZ, которого в манифесте набора нет", + want: "prefix ZZZZ, which the suite manifest does not declare", }, { - name: "тема не объявлена в манифесте", + name: "topic not declared in the manifest", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, "topic: time", "topic: clocks", 1) }, - want: `тема "clocks" не объявлена`, + want: `topic "clocks" is not declared`, }, { - name: "тема значится среди выбывших", + name: "topic listed among the retired ones", setup: func(f files) { f["suite.toml"] = strings.Replace(baseManifest, "[topics.retired]", `[topics.retired]`+"\ntime = \"снята 2026-07-01\"", 1) }, - want: "значится и среди живых, и среди выбывших", + want: "is listed both live and retired", }, { - name: "у живой темы нет слоёв", + name: "live topic without layers", setup: func(f files) { f["suite.toml"] = strings.Replace(baseManifest, `time = "время: хранение, зоны, форматы"`, `time = "время"`+"\nlogging = \"логирование\"", 1) }, - want: `тема "logging" объявлена живой, а слоёв у неё в наборе нет`, + want: `topic "logging" is declared live while the suite holds no layer of it`, }, { - name: "объявленного файла нет", + name: "declared file is missing", setup: func(f files) { f["suite.toml"] = strings.Replace(baseManifest, `TIME = "conventions/time.md"`, `TIME = "conventions/time.md"`+"\nSLOG = \"conventions/logging.md\"", 1) }, - want: `префикс SLOG объявлен за файлом "conventions/logging.md", а файла в наборе нет`, + want: `prefix SLOG is assigned to the file "conventions/logging.md", which the suite does not hold`, }, { - name: "файл не зарегистрирован в манифесте", + name: "file not registered in the manifest", setup: func(f files) { f["conventions/logging.md"] = "---\ntopic: logging\nprefix: SLOG\n---\n\n# Логирование\n" }, - want: "в манифесте набора не объявлен", + want: "not declared in the suite manifest", }, { - name: "префикс начинается на X", + name: "prefix starts with X", setup: func(f files) { f["suite.toml"] = strings.Replace(baseManifest, `TIME = "conventions/time.md"`, `XTIM = "conventions/time.md"`, 1) f["conventions/time.md"] = strings.ReplaceAll(baseTime, "TIME", "XTIM") }, - want: "зарезервирована за локальными правилами потребителей", + want: "reserved for the local rules of consumers", }, { - name: "на один файл объявлено два префикса", + name: "one file with two prefixes declared", setup: func(f files) { f["suite.toml"] = strings.Replace(baseManifest, `TIME = "conventions/time.md"`, `TIME = "conventions/time.md"`+"\nGTIM = \"conventions/time.md\"", 1) }, - want: "объявлено несколько префиксов", + want: "has several prefixes declared for it", }, { - name: "ключ манифеста неизвестен", + name: "unknown manifest key", setup: func(f files) { f["suite.toml"] = baseManifest + "\n[extra]\nkey = 1\n" }, - want: "инструменту неизвестен", + want: "is unknown to the tool", }, { - name: "метка механизации в тексте конвенции", + name: "mechanization mark in convention text", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, "Без явного смещения", "Правило МЕХАНИЗИРОВАНО линтером. Без явного смещения", 1) }, - want: "её место — запись о механизации в локальной части копии", + want: "its place is the note of mechanization in the local part of the copy", }, { - name: "путь канона в тексте конвенции", + name: "canon path in convention text", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, "Без явного смещения", "Смотри conventions/time.md. Без явного смещения", 1) }, - want: "стоит путь файла канона", + want: "the text holds the canon file path", }} for _, tc := range cases { @@ -324,7 +331,7 @@ func TestChecks(t *testing.T) { tc.setup(f) got := messages(run(t, f)) if !strings.Contains(got, tc.want) { - t.Fatalf("проверка не сработала\nждали: %s\nполучили:\n%s", tc.want, got) + t.Fatalf("the check did not fire\nwanted: %s\ngot:\n%s", tc.want, got) } }) } diff --git a/internal/check/form.go b/internal/check/form.go index bf0d60b..45c7d04 100644 --- a/internal/check/form.go +++ b/internal/check/form.go @@ -12,9 +12,9 @@ import ( "git.vakhrushev.me/av/convy/internal/suite" ) -// checkForm проверяет форму правила разбором текста. Применяется к любому -// файлу, который язык употребляет, — и к конвенциям, и к документу, которым -// набор ведёт себя сам. +// checkForm checks the form of a rule by parsing text. It applies to any file +// the language employs — both the conventions and the document the suite +// governs itself by. func checkForm(s *suite.Suite, d *doc.Document, rep *Report) { prefix := checkFilePrefix(s, d, rep) checkHeadings(d, prefix, rep) @@ -25,47 +25,47 @@ func checkForm(s *suite.Suite, d *doc.Document, rep *Report) { checkForeignVocabulary(s, d, rep) } -// checkFilePrefix сверяет префикс в шапке с манифестом и возвращает префикс, -// которым файлу положено пользоваться. +// checkFilePrefix reconciles the prefix in the front matter with the manifest +// and returns the prefix the file is supposed to use. func checkFilePrefix(s *suite.Suite, d *doc.Document, rep *Report) string { declared, _ := s.Manifest.PrefixOf(d.Path) if !d.Front.Present { - rep.Errorf(Form, d.Path, 1, "у файла нет шапки, а манифест объявляет за ним префикс %s", declared) + rep.Errorf(Form, d.Path, 1, "the file has no front matter, while the manifest assigns prefix %s to it", declared) return declared } if d.Front.Prefix == "" { - rep.Errorf(Form, d.Path, 1, "шапка не несёт ключа prefix") + rep.Errorf(Form, d.Path, 1, "the front matter carries no prefix key") return declared } at := d.Front.At["prefix"] if d.Front.Prefix != declared { rep.Errorf(Form, d.Path, at, - "префикс в шапке — %s, а манифест объявляет за этим файлом %s", d.Front.Prefix, declared) + "the front matter declares prefix %s, while the manifest assigns %s to this file", d.Front.Prefix, declared) } if err := manifest.ValidPrefix(d.Front.Prefix); err != nil { rep.Errorf(Form, d.Path, at, "%s", err) } if s.Manifest.PrefixRetired(d.Front.Prefix) { - rep.Errorf(Form, d.Path, at, "префикс %s значится среди выбывших", d.Front.Prefix) + rep.Errorf(Form, d.Path, at, "prefix %s is listed among the retired ones", d.Front.Prefix) } for _, key := range d.Front.Unknown { - rep.Warnf(Form, d.Path, d.Front.At[key], "ключ шапки %q инструменту неизвестен", key) + rep.Warnf(Form, d.Path, d.Front.At[key], "front matter key %q is unknown to the tool", key) } return declared } -// checkHeadings проверяет форму заголовков правил: собственный префикс файла, -// третий уровень, точка после идентификатора, название. +// checkHeadings checks the form of rule headings: the file's own prefix, the +// third level, a period after the identifier, a title. func checkHeadings(d *doc.Document, prefix string, rep *Report) { for _, r := range d.Rules { if r.Prefix != prefix { rep.Errorf(Form, d.Path, r.Line, - "заголовок правила использует префикс %s, а файлу принадлежит %s", r.Prefix, prefix) + "the rule heading uses prefix %s, while the file owns %s", r.Prefix, prefix) } if r.HeadingLevel != 3 { rep.Errorf(Form, d.Path, r.Line, - "заголовок правила %s стоит на уровне %d, а правило — заголовок третьего уровня", r.ID(), r.HeadingLevel) + "the heading of rule %s sits at level %d, while a rule is a third-level heading", r.ID(), r.HeadingLevel) } if r.Malformed != "" { rep.Errorf(Form, d.Path, r.Line, "%s: %s", r.ID(), r.Malformed) @@ -73,10 +73,10 @@ func checkHeadings(d *doc.Document, prefix string, rep *Report) { } } -// checkNumbering проверяет сплошную нумерацию: от единицы до наибольшего без -// пропусков и без повторов (META-31). Дыра неотличима от опечатки в номере и -// от правила, которое забыли дописать, — поэтому её нет никогда, а снятое -// правило остаётся заглушкой. +// checkNumbering checks that numbering is contiguous: from one up to the +// highest, with no gaps and no repeats (META-31). A gap is indistinguishable +// from a typo in a number and from a rule someone forgot to finish — which is +// why there is never one, and a retired rule stays as a stub. func checkNumbering(d *doc.Document, prefix string, rep *Report) { seen := make(map[int][]int) for _, r := range d.Rules { @@ -100,7 +100,7 @@ func checkNumbering(d *doc.Document, prefix string, rep *Report) { for _, n := range nums { if lines := seen[n]; len(lines) > 1 { rep.Errorf(Form, d.Path, lines[1], - "номер %s занят дважды: строки %s", ruleID(prefix, n), joinInts(lines)) + "number %s is taken twice: lines %s", ruleID(prefix, n), joinInts(lines)) } } var gaps []int @@ -111,15 +111,15 @@ func checkNumbering(d *doc.Document, prefix string, rep *Report) { } if len(gaps) > 0 { rep.Errorf(Form, d.Path, d.Rules[0].Line, - "нумерация не сплошная: наибольший номер %d, пропущены %s — снятое правило остаётся заглушкой, а не исчезает", + "numbering is not contiguous: the highest number is %d, missing %s — a retired rule stays as a stub instead of disappearing", highest, joinInts(gaps)) } } var dateRe = regexp.MustCompile(`\d{4}-\d{2}-\d{2}`) -// checkRules проверяет состав правила: либо норма с обоснованием, либо -// заглушка снятого. Ни норма, ни обоснование не удаляются никогда +// checkRules checks what a rule is made of: either a norm with a rationale, or +// the stub of a retired one. Neither the norm nor the rationale is ever deleted // (META-8, META-10). func checkRules(s *suite.Suite, d *doc.Document, rep *Report) { v := s.Vocab @@ -133,25 +133,25 @@ func checkRules(s *suite.Suite, d *doc.Document, rep *Report) { switch len(norms) { case 0: rep.Errorf(Form, d.Path, r.Line, - "у правила %s нет ни блока нормы, ни заглушки %s", r.ID(), v.MarkWord(lang.Retired)) + "rule %s has neither a norm block nor a %s stub", r.ID(), v.MarkWord(lang.Retired)) case 1: if norms[0].Rest == "" { rep.Errorf(Form, d.Path, norms[0].Start, - "у правила %s метка %s не открывает нормы: за ней пусто", r.ID(), norms[0].Word) + "in rule %s the %s mark opens no norm: nothing follows it", r.ID(), norms[0].Word) } default: rep.Errorf(Form, d.Path, norms[1].Start, - "у правила %s две нормы (%s и %s): норма — одна фраза, иначе нарушение одной её половины нечем адресовать", + "rule %s holds two norms (%s and %s): a norm is a single statement, otherwise a violation of one half of it has no address", r.ID(), norms[0].Word, norms[1].Word) } rationale, ok := r.Block(lang.Rationale) if !ok { rep.Errorf(Form, d.Path, r.Line, - "у правила %s нет блока %s: обоснование обязательно", r.ID(), v.MarkWord(lang.Rationale)) + "rule %s has no %s block: the rationale is mandatory", r.ID(), v.MarkWord(lang.Rationale)) } else if len(norms) > 0 && rationale.Start < norms[0].Start { rep.Errorf(Form, d.Path, rationale.Start, - "у правила %s обоснование стоит раньше нормы: порядок блоков — норма, %s, %s", + "in rule %s the rationale precedes the norm: the order of blocks is norm, %s, %s", r.ID(), v.MarkWord(lang.Rationale), v.MarkWord(lang.Examples)) } @@ -159,57 +159,59 @@ func checkRules(s *suite.Suite, d *doc.Document, rep *Report) { switch { case len(r.Blocks) > 0 && r.Blocks[0].Start == examples.Start: rep.Errorf(Form, d.Path, examples.Start, - "у правила %s блок %s открывает правило: порядок блоков — норма, %s, %s", + "in rule %s the %s block opens the rule: the order of blocks is norm, %s, %s", r.ID(), v.MarkWord(lang.Examples), v.MarkWord(lang.Rationale), v.MarkWord(lang.Examples)) case ok && rationale.Start > examples.Start: rep.Errorf(Form, d.Path, examples.Start, - "у правила %s блок %s стоит раньше обоснования: сначала требование, потом причина, потом иллюстрация", + "in rule %s the %s block precedes the rationale: the requirement first, then the reason, then the illustration", r.ID(), v.MarkWord(lang.Examples)) } } } } -// checkRetired проверяет заглушку снятого правила: дата и причина. +// checkRetired checks the stub of a retired rule: a date and a reason. func checkRetired(d *doc.Document, r doc.Rule, retired doc.Block, rep *Report) { if len(r.Norms()) > 0 { rep.Errorf(Form, d.Path, retired.Start, - "у снятого правила %s остался блок нормы: норму с обоснованием заменяет заглушка", r.ID()) + "retired rule %s still holds a norm block: the stub replaces the norm together with the rationale", r.ID()) } if !dateRe.MatchString(d.Line(retired.Start)) { rep.Errorf(Form, d.Path, retired.Start, - "заглушка правила %s не несёт даты снятия", r.ID()) + "the stub of rule %s carries no date of retirement", r.ID()) } if strings.TrimSpace(retired.Rest) == "" { rep.Errorf(Form, d.Path, retired.Start, - "заглушка правила %s не несёт причины снятия", r.ID()) + "the stub of rule %s carries no reason for retirement", r.ID()) } } -// checkVersionLine ищет во вводной прозе строку о версии языка и возвращает -// границы абзаца, который её несёт. +// checkVersionLine looks for the language version line in the introductory +// prose and returns the bounds of the paragraph carrying it. // -// Строка перечисляет ключевые слова набора и сама несёт правило заглавных — -// поэтому она единственное место вне правил, где модальные слова законны. +// The line lists the key words of the suite and carries the rule of capitals +// itself — which makes it the only place outside rules where modal words are +// lawful. func checkVersionLine(s *suite.Suite, d *doc.Document, rep *Report) (from, to int) { p, ok := versionParagraph(s, d) if !ok { start, _ := d.Preamble() rep.Errorf(Form, d.Path, start, - "во вводной прозе нет строки о версии языка: она перечисляет ключевые слова набора и без неё конвенция в чужом репозитории теряет ключ к собственному тексту") + "the introductory prose holds no language version line: it lists the key words of the suite, and without it a convention in a foreign repository loses the key to its own text") return 0, 0 } version := strconv.Itoa(s.Manifest.Language.Version) if !containsNumber(p.Text(), version) { rep.Errorf(Form, d.Path, p.Start, - "строка о версии языка не называет версию %s, объявленную манифестом набора", version) + "the language version line does not name version %s declared by the suite manifest", version) } return p.Start, p.End } -// versionParagraph ищет во вводной прозе абзац, несущий строку о версии языка: -// тот, где перечислены все ключевые слова набора. Ничего не сообщает — о его -// отсутствии говорит checkVersionLine, и говорить дважды незачем. +// versionParagraph looks in the introductory prose for the paragraph carrying +// the language version line: the one listing every key word of the suite. It +// reports nothing — checkVersionLine speaks about its absence, and speaking +// twice helps no one. func versionParagraph(s *suite.Suite, d *doc.Document) (doc.Paragraph, bool) { from, to := d.Preamble() words := s.Vocab.Words() @@ -221,9 +223,9 @@ func versionParagraph(s *suite.Suite, d *doc.Document) (doc.Paragraph, bool) { return doc.Paragraph{}, false } -// checkModalsOutside ищет заглавные модальные слова вне областей правил. -// Область — от заголовка правила до следующего заголовка; всё остальное проза, -// а проза нормой не является никогда. +// checkModalsOutside looks for capitalized modal words outside rule areas. An +// area runs from the heading of a rule to the next heading; everything else is +// prose, and prose is never a norm. func checkModalsOutside(s *suite.Suite, d *doc.Document, versionFrom, versionTo int, rep *Report) { words := modalWords(s.Vocab) d.Prose(func(n int, text string) bool { @@ -235,16 +237,16 @@ func checkModalsOutside(s *suite.Suite, d *doc.Document, versionFrom, versionTo continue } rep.Errorf(Form, d.Path, n, - "модальное слово %s стоит вне области правила: заглавное написание нормативно, и в прозе его быть не может", w) + "the modal word %s stands outside a rule area: capitalized spelling is normative, and prose cannot hold it", w) break } return true }) } -// checkForeignVocabulary ищет слова чужого словаря той же версии языка. -// Словарь один на набор: две формы записи одного требования удваивают каждую -// проверку. +// checkForeignVocabulary looks for words of another vocabulary of the same +// language version. There is one vocabulary per suite: two ways of writing the +// same requirement double every check. func checkForeignVocabulary(s *suite.Suite, d *doc.Document, rep *Report) { foreign := lang.Foreign(s.Manifest.Language.Version, s.Manifest.Language.Lang) if len(foreign) == 0 { @@ -260,7 +262,7 @@ func checkForeignVocabulary(s *suite.Suite, d *doc.Document, rep *Report) { for _, w := range words { if containsWord(text, w) { rep.Errorf(Form, d.Path, n, - "слово %s принадлежит словарю %q, а набор объявляет словарь %q", + "the word %s belongs to the %q vocabulary, while the suite declares %q", w, foreign[w], s.Manifest.Language.Lang) } } @@ -286,8 +288,8 @@ func containsAll(text string, words []string) bool { return true } -// containsWord ищет слово как целое: «ДОЛЖЕНСТВОВАНИЕ» словом ДОЛЖЕН не -// является, а «ДОЛЖЕН.» — является. +// containsWord looks for a word as a whole: "MUSTARD" is not the word "MUST", +// while "MUST." is. func containsWord(text, word string) bool { for i := 0; ; { j := strings.Index(text[i:], word) diff --git a/internal/check/layers_test.go b/internal/check/layers_test.go index 4f8260d..7fb5ec8 100644 --- a/internal/check/layers_test.go +++ b/internal/check/layers_test.go @@ -5,9 +5,9 @@ import ( "testing" ) -// Набор с двумя слоями одной темы: базовый арх-слой и языковой поверх него. -// На нём проверяется всё, что про оси, extends и границу самодостаточности -// нормы, — на одном слое эти проверки выразить нечем. +// A suite with two layers of one topic: a base architectural layer and a +// language layer on top of it. Everything about axes, extends and the boundary +// of a self-sufficient norm is checked here — a single layer cannot express it. const layeredManifest = ` [language] @@ -104,7 +104,7 @@ func layered() files { func TestLayeredSuiteIsClean(t *testing.T) { if got := run(t, layered()); len(got) != 0 { - t.Fatalf("исправный многослойный набор дал находки:\n%s", messages(got)) + t.Fatalf("a sound layered suite produced findings:\n%s", messages(got)) } } @@ -114,27 +114,27 @@ func TestLayeredChecks(t *testing.T) { setup func(files) want string }{{ - name: "ось в шапке расходится с путём", + name: "axis in the front matter diverges from the path", setup: func(f files) { f["conventions/lang/go/time.md"] = strings.Replace(goTime, "lang: go", "lang: python", 1) }, - want: `путь кладёт файл на ось lang=go, а шапка объявляет lang="python"`, + want: `the path puts the file on axis lang=go, while the front matter declares lang="python"`, }, { - name: "extends ведёт в чужую тему", + name: "extends leads into a foreign topic", setup: func(f files) { f["conventions/lang/go/time.md"] = strings.Replace(goTime, "extends: arch/time.md", "extends: arch/logging.md", 1) }, - want: `с темой "logging", а файл несёт тему "time"`, + want: `with topic "logging", while the file carries topic "time"`, }, { - name: "extends ведёт в несуществующий файл", + name: "extends leads into a file that does not exist", setup: func(f files) { f["conventions/lang/go/time.md"] = strings.Replace(goTime, "extends: arch/time.md", "extends: arch/clocks.md", 1) }, - want: `а такого файла в наборе нет`, + want: `and the suite holds no such file`, }, { - name: "у темы два слоя без ключей оси", + name: "topic with two layers lacking axis keys", setup: func(f files) { f["suite.toml"] = strings.Replace(layeredManifest, `GTIM = "conventions/lang/go/time.md"`, @@ -144,23 +144,23 @@ func TestLayeredChecks(t *testing.T) { strings.Replace(goTime, "lang: go\n", "", 1), "extends: arch/time.md\n", "", 1) }, - want: "больше одного слоя без ключей оси", + want: "more than one layer without axis keys", }, { - name: "норма ссылается на префикс чужой темы", + name: "norm references the prefix of a foreign topic", setup: func(f files) { f["conventions/lang/go/time.md"] = strings.Replace(goTime, "**ДОЛЖЕН.** Текущее время приходит из store.Now().", "**ДОЛЖЕН.** Текущее время приходит из store.Now() и пишется по SLOG-1.", 1) }, - want: `ссылается на SLOG-1 из чужой темы "logging"`, + want: `refers to SLOG-1 from the foreign topic "logging"`, }, { - name: "норма ссылается на неба́зовый слой своей темы", + name: "norm references a non-base layer of its own topic", setup: func(f files) { f["conventions/arch/time.md"] = strings.Replace(archTime, "**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.", "**ДОЛЖЕН.** Момент времени записывается с суффиксом Z, как требует GTIM-1.", 1) }, - want: "слой своей темы, но не базовый", + want: "a layer of its own topic but not the base one", }} for _, tc := range cases { @@ -169,55 +169,55 @@ func TestLayeredChecks(t *testing.T) { tc.setup(f) got := messages(run(t, f)) if !strings.Contains(got, tc.want) { - t.Fatalf("проверка не сработала\nждали: %s\nполучили:\n%s", tc.want, got) + t.Fatalf("the check did not fire\nwanted: %s\ngot:\n%s", tc.want, got) } }) } } -// TestNoFalsePositives собирает случаи, в которых проверка обязана промолчать. -// Ложное срабатывание здесь дороже пропуска: проверку, которая краснеет на -// исправном файле, выключают целиком. +// TestNoFalsePositives gathers the cases where a check must stay silent. A +// false positive costs more here than a miss: a check that goes red on a sound +// file gets switched off altogether. func TestNoFalsePositives(t *testing.T) { cases := []struct { name string setup func(files) }{{ - name: "идентификатор в бэктиках — образец записи, а не ссылка", + name: "an identifier in backticks is a sample of notation, not a reference", setup: func(f files) { f["conventions/arch/time.md"] = strings.Replace(archTime, "Без явного смещения", "На правило ссылаются идентификатором (`TIME-99`). Без явного смещения", 1) }, }, { - name: "модальное слово внутри огороженного блока кода", + name: "a modal word inside a fenced code block", setup: func(f files) { f["conventions/arch/time.md"] = archTime + "\n## Связано\n\n```\nДОЛЖЕН это не норма, а строка примера\n```\n" }, }, { - name: "заглавное SQL-слово в примере кода", + name: "an uppercase SQL keyword in a code sample", setup: func(f files) { f["conventions/arch/time.md"] = strings.Replace(archTime, "**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.", "**ПОЧЕМУ.** Без явного смещения не видно зоны.\n\n```sql\nSELECT 1 WHERE a AND b OR c\n```", 1) }, }, { - name: "норма языкового слоя ссылается на базовый слой своей темы", + name: "a language layer's norm references the base layer of its own topic", setup: func(f files) { f["conventions/lang/go/time.md"] = strings.Replace(goTime, "**ДОЛЖЕН.** Текущее время приходит из store.Now().", "**ДОЛЖЕН.** Текущее время приходит из store.Now() в форме TIME-1.", 1) }, }, { - name: "упоминание ступени в обосновании — не вторая норма", + name: "a mention of a step in the rationale is not a second norm", setup: func(f files) { f["conventions/arch/time.md"] = strings.Replace(archTime, "**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.", "**ПОЧЕМУ.** Для ступени СЛЕДУЕТ это было бы честно, но здесь ломается сортировка.", 1) }, }, { - name: "заглушка снятого правила с датой и причиной", + name: "a stub of a retired rule with a date and a reason", setup: func(f files) { f["conventions/arch/time.md"] = archTime + "\n### TIME-2. Ширина строки фиксируется\n\n**СНЯТО 2026-07-26.** Правило переехало в GTIM-1.\n" @@ -229,7 +229,7 @@ func TestNoFalsePositives(t *testing.T) { f := layered() tc.setup(f) if got := run(t, f); len(got) != 0 { - t.Fatalf("проверка сработала там, где не должна:\n%s", messages(got)) + t.Fatalf("a check fired where it must not:\n%s", messages(got)) } }) } diff --git a/internal/check/refs.go b/internal/check/refs.go index 67f9af4..d366a6d 100644 --- a/internal/check/refs.go +++ b/internal/check/refs.go @@ -9,11 +9,11 @@ import ( "git.vakhrushev.me/av/convy/internal/suite" ) -// refRe ловит идентификатор правила: четыре заглавные латинские буквы, дефис, -// номер и необязательный номер строки таблицы. +// refRe catches a rule identifier: four uppercase Latin letters, a hyphen, a +// number and an optional table row number. var refRe = regexp.MustCompile(`\b([A-Z]{4})-(\d+)(?:\.(\d+))?`) -// Ref — ссылка на правило, найденная в тексте. +// Ref is a reference to a rule found in the text. type Ref struct { Prefix string Num int @@ -22,13 +22,13 @@ type Ref struct { Text string } -// refsIn собирает ссылки в диапазоне строк документа. Инлайн-код вырезан: в -// бэктиках идентификатор стоит образцом записи, а не ссылкой на утверждение, — -// иначе строка «на конкретное правило ссылаются идентификатором (`SLOG-27`)» -// требовала бы, чтобы правило SLOG-27 существовало. +// refsIn collects the references in a range of lines. Inline code is cut out: +// inside backticks an identifier stands as a sample of the notation rather than +// as a reference to an assertion — otherwise the line "a rule is referred to by +// its identifier (`SLOG-27`)" would demand that rule SLOG-27 exist. // -// Заголовки правил пропускаются: заголовок правило объявляет, а не ссылается -// на него, и разрешать его по манифесту не к чему. +// Rule headings are skipped: a heading declares a rule instead of referring to +// one, and there is nothing to resolve against the manifest. func refsIn(d *doc.Document, from, to int) []Ref { heading := make(map[int]bool, len(d.Rules)) for _, r := range d.Rules { @@ -60,48 +60,48 @@ func refsInLine(n int, text string) []Ref { return out } -// checkLinks проверяет, что каждая ссылка разрешается. Неразрешённый -// идентификатор всегда ошибка: с заглушками на месте снятых правил третьего -// исхода нет — ссылка ведёт либо к правилу, либо к объяснению, почему его -// сняли (META-31, META-32). +// checkLinks verifies that every reference resolves. An unresolved identifier +// is always an error: with stubs standing in for retired rules there is no +// third outcome — a reference leads either to a rule or to the explanation of +// why it was retired (META-31, META-32). func checkLinks(s *suite.Suite, d *doc.Document, rep *Report) { for _, ref := range refsIn(d, d.Body, d.Len()) { if strings.HasPrefix(ref.Prefix, "X") { - // Префикс потребителя: локальные правила чужого репозитория - // набору не видны и разрешению не подлежат. + // A consumer's prefix: the local rules of a foreign repository + // are invisible to the suite and are not to be resolved. continue } if s.Manifest.PrefixRetired(ref.Prefix) { rep.Errorf(Links, d.Path, ref.Line, - "ссылка %s ведёт на выбывший префикс %s", ref.Text, ref.Prefix) + "reference %s points at retired prefix %s", ref.Text, ref.Prefix) continue } target, ok := s.ByPrefix[ref.Prefix] if !ok { if _, declared := s.Manifest.PathOf(ref.Prefix); declared { - // Файл объявлен, но не прочитан — о нём уже сказано - // проверкой манифеста, второй раз не повторяем. + // The file is declared but was not read — the manifest check + // has already said so, and saying it twice helps no one. continue } rep.Errorf(Links, d.Path, ref.Line, - "ссылка %s ведёт на префикс %s, которого в манифесте набора нет", ref.Text, ref.Prefix) + "reference %s points at prefix %s, which the suite manifest does not declare", ref.Text, ref.Prefix) continue } rule, ok := ruleByNum(target, ref.Num) if !ok { rep.Errorf(Links, d.Path, ref.Line, - "ссылка %s не разрешается: в %s правила с номером %d нет", ref.Text, target.Path, ref.Num) + "reference %s does not resolve: %s holds no rule numbered %d", ref.Text, target.Path, ref.Num) continue } if ref.Sub > 0 && !mentions(target, rule, ref.Text) { rep.Errorf(Links, d.Path, ref.Line, - "ссылка %s не разрешается: в области %s такой строки нет", ref.Text, rule.ID()) + "reference %s does not resolve: the area of %s holds no such row", ref.Text, rule.ID()) } } } -// mentions отвечает, встречается ли текст ссылки в области правила. Так -// проверяется номер строки таблицы: сама строка его и несёт. +// mentions reports whether the text of a reference occurs inside the area of a +// rule. That is how a table row number is checked: the row itself carries it. func mentions(d *doc.Document, rule doc.Rule, text string) bool { for n := rule.Line; n <= rule.End; n++ { if strings.Contains(d.Line(n), text) { diff --git a/internal/check/report.go b/internal/check/report.go index a79eaa8..7bdff56 100644 --- a/internal/check/report.go +++ b/internal/check/report.go @@ -1,11 +1,11 @@ -// Package check прогоняет проверки целостности набора. +// Package check runs the integrity checks of a suite. // -// Деление на семейства взято из языка и сохранено в коде: форма правила -// проверяется в любом файле, который язык употребляет; распространение — только -// в файлах конвенций, потому что эти проверки о том, что документ уезжает к -// потребителю. Третья часть списка — взаимоисключительность строк таблицы, -// покрытие области действия, самодостаточность нормы — сюда не входит: она не -// даётся разбором текста и остаётся работой читателя. +// The split into families is taken from the language and kept in the code: the +// form of a rule is checked in any file the language employs; spread only in +// convention files, because those checks are about a document travelling to a +// consumer. The third part of the list — rows of a table being mutually +// exclusive, the scope being covered, a norm being self-sufficient — is not +// here: it does not yield to parsing text and stays the reader's work. package check import ( @@ -13,8 +13,8 @@ import ( "sort" ) -// Severity различает ошибку и предупреждение. Ошибка — нарушение, названное -// правилом набора; предупреждение — то, что стоит посмотреть глазами. +// Severity tells an error from a warning. An error is a violation named by a +// rule of the suite; a warning is something worth a look. type Severity int const ( @@ -24,43 +24,44 @@ const ( func (s Severity) String() string { if s == Warning { - return "предупреждение" + return "warning" } - return "ошибка" + return "error" } -// Family — семейство проверок, из которого пришла находка. +// Family is the family of checks a finding came from. type Family string const ( - Manifest Family = "манифест" - Form Family = "форма" - Spread Family = "распространение" - Links Family = "ссылки" + Manifest Family = "manifest" + Form Family = "form" + Spread Family = "spread" + Links Family = "links" ) -// Finding — одна находка. +// Finding is a single finding. type Finding struct { Severity Severity Family Family - // Path — путь файла от корня набора; пусто, если находка о наборе целиком. + // Path is the path of the file from the root of the suite; empty when the + // finding is about the suite as a whole. Path string - // Line — строка файла; ноль, если находка не привязана к строке. + // Line is the line of the file; zero when the finding is not bound to one. Line int Msg string } -// Report накапливает находки одного прогона. +// Report accumulates the findings of one run. type Report struct { findings []Finding } -// Errorf записывает ошибку. +// Errorf records an error. func (r *Report) Errorf(f Family, path string, line int, format string, args ...any) { r.add(Error, f, path, line, format, args...) } -// Warnf записывает предупреждение. +// Warnf records a warning. func (r *Report) Warnf(f Family, path string, line int, format string, args ...any) { r.add(Warning, f, path, line, format, args...) } @@ -75,8 +76,7 @@ func (r *Report) add(s Severity, f Family, path string, line int, format string, }) } -// Findings отдаёт находки в порядке файла и строки. Находки о наборе целиком -// идут первыми: пока манифест не сходится, остальное читать рано. +// Findings hands over the findings ordered by file and line. func (r *Report) Findings() []Finding { out := make([]Finding, len(r.findings)) copy(out, r.findings) @@ -89,7 +89,7 @@ func (r *Report) Findings() []Finding { return out } -// Errors считает находки уровня ошибки. +// Errors counts the findings of error severity. func (r *Report) Errors() int { n := 0 for _, f := range r.findings { @@ -100,7 +100,7 @@ func (r *Report) Errors() int { return n } -// Warnings считает предупреждения. +// Warnings counts the warnings. func (r *Report) Warnings() int { return len(r.findings) - r.Errors() } diff --git a/internal/check/spread.go b/internal/check/spread.go index 7f30c54..fa8045a 100644 --- a/internal/check/spread.go +++ b/internal/check/spread.go @@ -10,9 +10,9 @@ import ( "git.vakhrushev.me/av/convy/internal/suite" ) -// checkSpread проверяет то, что относится к отъезду документа к потребителю. -// Применяется только к файлам конвенций: документ, которым набор ведёт себя -// сам, не уезжает никуда, и путь канона в нём законен. +// checkSpread checks what bears on a document travelling to a consumer. It +// applies to convention files only: the document the suite governs itself by +// travels nowhere, and a canon path inside it is lawful. func checkSpread(s *suite.Suite, d *doc.Document, rep *Report) { checkTopic(s, d, rep) checkAxis(d, rep) @@ -22,23 +22,25 @@ func checkSpread(s *suite.Suite, d *doc.Document, rep *Report) { checkForeignTopicPrefix(s, d, rep) } -// checkTopic сверяет тему из шапки с манифестом (META-28, META-29). +// checkTopic reconciles the topic from the front matter with the manifest +// (META-28, META-29). func checkTopic(s *suite.Suite, d *doc.Document, rep *Report) { topic := d.Front.Topic at := d.Front.At["topic"] switch { case s.Manifest.TopicRetired(topic): rep.Errorf(Spread, d.Path, at, - "тема %q значится среди выбывших: снятое имя другой теме не выдаётся", topic) + "topic %q is listed among the retired ones: a retired name is never handed to another topic", topic) case !s.Manifest.TopicLive(topic): rep.Errorf(Spread, d.Path, at, - "тема %q не объявлена в манифесте набора", topic) + "topic %q is not declared in the suite manifest", topic) } } -// checkAxis сверяет объявленную ось с путём файла. Ось объявляется в шапке, а -// не выводится из пути (META-38); но если директории осей используются, -// расхождение означает переезд файла без правки шапки. +// checkAxis reconciles the declared axis with the path of the file. An axis is +// declared in the front matter rather than derived from the path (META-38); but +// once axis directories are in use, a divergence means the file moved and the +// front matter did not. func checkAxis(d *doc.Document, rep *Report) { parts := strings.Split(path.Dir(d.Path), "/") for i := 0; i+1 < len(parts); i++ { @@ -59,13 +61,13 @@ func checkAxis(d *doc.Document, rep *Report) { at = d.Front.At["prefix"] } rep.Errorf(Spread, d.Path, at, - "путь кладёт файл на ось %s=%s, а шапка объявляет %s=%q", key, parts[i+1], key, declared) + "the path puts the file on axis %s=%s, while the front matter declares %s=%q", key, parts[i+1], key, declared) } } -// checkExtends проверяет, что объявленная база существует и принадлежит той же -// теме. Ключ документирует связь слоёв для человека — документация, которая -// врёт, хуже отсутствующей. +// checkExtends verifies that the declared base exists and belongs to the same +// topic. The key documents the tie between layers for a human — and +// documentation that lies is worse than none. func checkExtends(s *suite.Suite, d *doc.Document, rep *Report) { if d.Front.Extends == "" { return @@ -74,22 +76,23 @@ func checkExtends(s *suite.Suite, d *doc.Document, rep *Report) { target := resolveExtends(s, d.Front.Extends) if target == nil { rep.Errorf(Spread, d.Path, at, - "extends указывает на %q, а такого файла в наборе нет", d.Front.Extends) + "extends points at %q, and the suite holds no such file", d.Front.Extends) return } if target.Front.Topic != d.Front.Topic { rep.Errorf(Spread, d.Path, at, - "extends указывает на %q с темой %q, а файл несёт тему %q: слои одной темы объявляют одно имя", + "extends points at %q with topic %q, while the file carries topic %q: the layers of one topic declare one name", d.Front.Extends, target.Front.Topic, d.Front.Topic) } if target.Front.Axis() { rep.Errorf(Spread, d.Path, at, - "extends указывает на %q, а это не базовый слой: у него объявлена ось", d.Front.Extends) + "extends points at %q, which is not a base layer: it declares an axis", d.Front.Extends) } } -// resolveExtends ищет документ по пути, записанному в extends. Путь даётся от -// директории конвенций, поэтому пробуем и его, и путь от корня набора. +// resolveExtends looks up the document at the path written in extends. The path +// is given from the conventions directory, so both it and a path from the root +// of the suite are tried. func resolveExtends(s *suite.Suite, ref string) *doc.Document { ref = path.Clean(strings.TrimPrefix(ref, "./")) for _, d := range s.Docs { @@ -100,9 +103,10 @@ func resolveExtends(s *suite.Suite, ref string) *doc.Document { return nil } -// checkMechanized ищет метку механизации в тексте конвенции. Механизирована -// норма или нет — свойство репозитория, а не набора, поэтому место отметки — -// локальная часть копии (META-7). +// checkMechanized looks for the mark of mechanization in the text of a +// convention. Whether a norm is mechanized is a property of a repository rather +// than of the suite, so the place of the mark is the local part of the copy +// (META-7). func checkMechanized(s *suite.Suite, d *doc.Document, rep *Report) { word := s.Vocab.MarkWord(lang.Mechanized) if word == "" { @@ -115,21 +119,22 @@ func checkMechanized(s *suite.Suite, d *doc.Document, rep *Report) { } if containsWord(text, word) { rep.Errorf(Spread, d.Path, n, - "метка %s стоит в тексте конвенции: её место — запись о механизации в локальной части копии", word) + "the %s mark stands in the text of a convention: its place is the note of mechanization in the local part of the copy", word) } return true }) } -// mdPathRe ловит то, что выглядит путём к файлу набора. +// mdPathRe catches what looks like a path to a file of the suite. var mdPathRe = regexp.MustCompile(`[\w./-]+\.md`) -// checkCanonPaths ищет путь файла канона в тексте конвенции (META-21). В -// репозитории потребителя конвенция лежит собранной, слои одной темы — секции -// одного файла, и путь `lang/go/logging.md` там не существует: ссылка на него -// умирает при сборке, причём молча — текст остаётся связным. +// checkCanonPaths looks for the path of a canon file in the text of a +// convention (META-21). In a consumer's repository a convention lies assembled, +// the layers of one topic are sections of one file, and the path +// `lang/go/logging.md` does not exist there: a reference to it dies on assembly, +// and dies in silence — the text stays coherent. // -// Инлайн-код здесь не вырезается: путь в бэктиках — тоже путь. +// Inline code is not cut out here: a path in backticks is still a path. func checkCanonPaths(s *suite.Suite, d *doc.Document, rep *Report) { for n := d.Body; n <= d.Len(); n++ { if d.Fenced(n) { @@ -141,16 +146,17 @@ func checkCanonPaths(s *suite.Suite, d *doc.Document, rep *Report) { continue } rep.Errorf(Spread, d.Path, n, - "в тексте стоит путь файла канона %q: ссылаются именем темы или идентификатором правила", candidate) + "the text holds the canon file path %q: refer by the name of a topic or the identifier of a rule", candidate) } } } -// checkForeignTopicPrefix ищет префикс чужой темы в блоке нормы (META-20). -// Норму правила можно исполнить, имея один этот файл: репозиторий подписывается -// на произвольное подмножество конвенций, и графа зависимостей у него нет. -// Префикс базового слоя своей темы там допустим (META-24) — собранный файл -// начинается с него независимо от выбранных языка и стека. +// checkForeignTopicPrefix looks for the prefix of a foreign topic inside a norm +// block (META-20). The norm of a rule must be executable holding this one file: +// a repository subscribes to an arbitrary subset of the conventions, and it has +// no dependency graph by construction. The prefix of the base layer of its own +// topic is allowed there (META-24) — an assembled file starts with that layer +// whatever language and stack were chosen. func checkForeignTopicPrefix(s *suite.Suite, d *doc.Document, rep *Report) { own := s.Prefix(d) for _, r := range d.Rules { @@ -165,13 +171,13 @@ func checkForeignTopicPrefix(s *suite.Suite, d *doc.Document, rep *Report) { } if target.Front.Topic != d.Front.Topic { rep.Errorf(Spread, d.Path, ref.Line, - "норма %s ссылается на %s из чужой темы %q: наружу смотрит только обоснование", + "the norm of %s refers to %s from the foreign topic %q: only the rationale looks outward", r.ID(), ref.Text, target.Front.Topic) continue } if target.Front.Axis() { rep.Errorf(Spread, d.Path, ref.Line, - "норма %s ссылается на %s — слой своей темы, но не базовый: в копию он попадает по манифесту, и гарантии, что он рядом, нет", + "the norm of %s refers to %s, a layer of its own topic but not the base one: that layer reaches the copy through the manifest, so there is no guarantee it stands nearby", r.ID(), ref.Text) } } diff --git a/internal/check/suite.go b/internal/check/suite.go index a3af5f7..ee577ba 100644 --- a/internal/check/suite.go +++ b/internal/check/suite.go @@ -7,7 +7,7 @@ import ( "git.vakhrushev.me/av/convy/internal/suite" ) -// Suite прогоняет все проверки набора и возвращает отчёт. +// Suite runs every check of a suite and returns the report. func Suite(s *suite.Suite) *Report { rep := &Report{} checkManifest(s, rep) @@ -23,17 +23,18 @@ func Suite(s *suite.Suite) *Report { return rep } -// checkManifest проверяет сам манифест: форму префиксов, непересечение живого -// с выбывшим, наличие объявленных файлов и отсутствие незарегистрированных. +// checkManifest checks the manifest itself: the shape of prefixes, live and +// retired not overlapping, the declared files being present and no undeclared +// ones lying around. func checkManifest(s *suite.Suite, rep *Report) { m := s.Manifest for _, key := range m.Undecoded { - rep.Warnf(Manifest, manifest.Name, 0, "ключ %s инструменту неизвестен", key) + rep.Warnf(Manifest, manifest.Name, 0, "the key %s is unknown to the tool", key) } for _, name := range []string{m.Language.Description, m.Language.Reading} { if name != "" && !s.Exists(name) { - rep.Errorf(Manifest, manifest.Name, 0, "секция [language] объявляет документ %q, а файла нет", name) + rep.Errorf(Manifest, manifest.Name, 0, "the [language] section declares the document %q, and the file is missing", name) } } @@ -44,7 +45,7 @@ func checkManifest(s *suite.Suite, rep *Report) { } if m.PrefixRetired(prefix) { rep.Errorf(Manifest, manifest.Name, 0, - "префикс %s значится и среди живых, и среди выбывших: выбывший не выдаётся повторно", prefix) + "prefix %s is listed both live and retired: a retired one is never reissued", prefix) } path := m.Prefixes.Live[prefix] byPath[path] = append(byPath[path], prefix) @@ -53,22 +54,22 @@ func checkManifest(s *suite.Suite, rep *Report) { if prefixes := byPath[path]; len(prefixes) > 1 { sort.Strings(prefixes) rep.Errorf(Manifest, manifest.Name, 0, - "на файл %q объявлено несколько префиксов (%v): префикс принадлежит файлу", path, prefixes) + "the file %q has several prefixes declared for it (%v): a prefix belongs to one file", path, prefixes) } } for prefix := range m.Prefixes.Retired { if err := manifest.ValidPrefix(prefix); err != nil { - rep.Errorf(Manifest, manifest.Name, 0, "среди выбывших: %s", err) + rep.Errorf(Manifest, manifest.Name, 0, "among the retired ones: %s", err) } } for _, prefix := range sortedKeys(s.Missing) { rep.Errorf(Manifest, manifest.Name, 0, - "префикс %s объявлен за файлом %q, а файла в наборе нет", prefix, s.Missing[prefix]) + "prefix %s is assigned to the file %q, which the suite does not hold", prefix, s.Missing[prefix]) } for _, path := range s.Unregistered { rep.Errorf(Manifest, path, 1, - "файл записан языком конвенций, но в манифесте набора не объявлен: для набора его нет") + "the file is written in the conventions language yet not declared in the suite manifest: for the suite it does not exist") } for _, err := range s.Broken { rep.Errorf(Manifest, manifest.Name, 0, "%s", err) @@ -77,18 +78,18 @@ func checkManifest(s *suite.Suite, rep *Report) { for _, topic := range m.LiveTopics() { if m.TopicRetired(topic) { rep.Errorf(Manifest, manifest.Name, 0, - "тема %q значится и среди живых, и среди выбывших", topic) + "topic %q is listed both live and retired", topic) } if len(s.Layers(topic)) == 0 { rep.Errorf(Manifest, manifest.Name, 0, - "тема %q объявлена живой, а слоёв у неё в наборе нет: тема живёт, пока есть хотя бы один слой", topic) + "topic %q is declared live while the suite holds no layer of it: a topic lives as long as at least one layer does", topic) } } } -// checkBaseLayers проверяет, что у темы не больше одного слоя без ключей оси. -// Базовый слой единственный: он попадает в копию всегда, и второй такой -// означал бы два базовых текста в одном собранном файле. +// checkBaseLayers checks that a topic holds no more than one layer without axis +// keys. The base layer is the only one of its kind: it reaches every copy, and a +// second such layer would mean two base texts in one assembled file. func checkBaseLayers(s *suite.Suite, rep *Report) { for _, topic := range s.Manifest.LiveTopics() { var base []string @@ -101,7 +102,7 @@ func checkBaseLayers(s *suite.Suite, rep *Report) { sort.Strings(base) for _, path := range base[1:] { rep.Errorf(Spread, path, 1, - "у темы %q больше одного слоя без ключей оси: базовый слой единственный, остальные — %v", + "topic %q holds more than one layer without axis keys: the base layer is the only one of its kind, and the candidates are %v", topic, base) } } diff --git a/internal/check/text.go b/internal/check/text.go index ddcda55..130701c 100644 --- a/internal/check/text.go +++ b/internal/check/text.go @@ -5,8 +5,8 @@ import ( "unicode/utf8" ) -// letterAt отвечает, стоит ли по смещению i буква или цифра. Кириллица в UTF-8 -// занимает два байта, поэтому решать по одному байту нельзя. +// letterAt reports whether offset i holds a letter or a digit. Cyrillic takes +// two bytes in UTF-8, so a single byte is not enough to decide. func letterAt(text string, i int) bool { if i >= len(text) { return false @@ -15,7 +15,7 @@ func letterAt(text string, i int) bool { return unicode.IsLetter(r) || unicode.IsDigit(r) } -// letterBefore отвечает, стоит ли перед смещением i буква или цифра. +// letterBefore reports whether a letter or a digit stands before offset i. func letterBefore(text string, i int) bool { if i <= 0 { return false diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 4161718..dbc5f2c 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -1,10 +1,10 @@ -// Package cli раскладывает команды инструмента. +// Package cli lays out the commands of the tool. // -// Глубина команды отражает частоту и адресата: проектные команды выполняются в -// каждом репозитории и часто, ведение набора — в одном репозитории и редко. -// Поэтому `check` стоит наверху, а под `suite` уходит то, что в проекте не -// имеет смысла. Синонимов нет: `convy suite pull` рядом с `convy pull` не -// заводится. +// The depth of a command reflects how often it runs and whom it addresses: +// project commands run in every repository and often, tending a suite runs in +// one repository and rarely. That is why `check` stays at the top level and +// whatever makes no sense in a project goes under `suite`. There are no +// synonyms: `convy suite pull` is not introduced next to `convy pull`. package cli import ( @@ -13,19 +13,20 @@ import ( "os" ) -// ExitCode — код возврата процесса. +// ExitCode is the exit status of the process. type ExitCode int const ( - // OK — проверка прошла, работа сделана. + // OK means the check passed and the work is done. OK ExitCode = 0 - // Failed — проверка нашла ошибки. + // Failed means the check found errors. Failed ExitCode = 1 - // Usage — команда набрана неверно или не в том контексте. + // Usage means the command was typed wrong or run in the wrong context. Usage ExitCode = 2 ) -// Env — окружение запуска. Вынесено, чтобы команды тестировались без процесса. +// Env is the environment of a run. It is pulled out so that commands can be +// tested without a process. type Env struct { Dir string Out io.Writer @@ -34,7 +35,7 @@ type Env struct { Colors bool } -// Run разбирает аргументы и выполняет команду. +// Run parses the arguments and executes the command. func Run(env Env, args []string) ExitCode { if len(args) == 0 { usage(env.Out) @@ -45,13 +46,13 @@ func Run(env Env, args []string) ExitCode { case "suite": return runSuite(env, args[1:]) case "add", "pull", "list", "check": - fmt.Fprintf(env.Err, "команда %q ещё не реализована\n", args[0]) + fmt.Fprintf(env.Err, "the %q command is not implemented yet\n", args[0]) return Usage case "help", "-h", "--help": usage(env.Out) return OK default: - fmt.Fprintf(env.Err, "неизвестная команда %q\n\n", args[0]) + fmt.Fprintf(env.Err, "unknown command %q\n\n", args[0]) usage(env.Err) return Usage } @@ -59,44 +60,43 @@ func Run(env Env, args []string) ExitCode { func runSuite(env Env, args []string) ExitCode { if len(args) == 0 { - fmt.Fprintln(env.Err, "convy suite: нужна подкоманда — check") + fmt.Fprintln(env.Err, "convy suite: a subcommand is required — check") return Usage } switch args[0] { case "check": return runSuiteCheck(env, args[1:]) case "new": - fmt.Fprintln(env.Err, "команда \"suite new\" ещё не реализована") + fmt.Fprintln(env.Err, "the \"suite new\" command is not implemented yet") return Usage default: - fmt.Fprintf(env.Err, "неизвестная подкоманда %q для convy suite\n", args[0]) + fmt.Fprintf(env.Err, "unknown subcommand %q for convy suite\n", args[0]) return Usage } } -// usage печатает помощь, сгруппированную заголовками: в плоском списке уровни -// не видны. +// usage prints the help grouped under headings: a flat list hides the levels. func usage(w io.Writer) { - fmt.Fprint(w, `convy — управление конвенциями разработки. + fmt.Fprint(w, `convy — tending development conventions. -В проекте: - convy add <тема> подписаться и собрать (не реализовано) - convy pull пересобрать подписанное (не реализовано) - convy list что подключено и что доступно (не реализовано) - convy check проверить форму того, что здесь (не реализовано) +In a project: + convy add subscribe and assemble (not implemented) + convy pull reassemble what is subscribed (not implemented) + convy list what is wired up and available (not implemented) + convy check check the form of what is here (not implemented) -В наборе: - convy suite check целостность набора: префиксы, темы, оси, ссылки, форма - convy suite new новая тема (не реализовано) +In a suite: + convy suite check suite integrity: prefixes, topics, axes, links, form + convy suite new a new topic (not implemented) `) } -// Main — точка входа процесса. +// Main is the entry point of the process. func Main() int { dir, err := os.Getwd() if err != nil { - fmt.Fprintln(os.Stderr, "не удалось определить текущую директорию:", err) + fmt.Fprintln(os.Stderr, "cannot determine the current directory:", err) return int(Usage) } env := Env{Dir: dir, Out: os.Stdout, Err: os.Stderr} diff --git a/internal/cli/suitecheck.go b/internal/cli/suitecheck.go index 48d0d28..7b05e94 100644 --- a/internal/cli/suitecheck.go +++ b/internal/cli/suitecheck.go @@ -16,8 +16,8 @@ import ( func runSuiteCheck(env Env, args []string) ExitCode { fs := flag.NewFlagSet("convy suite check", flag.ContinueOnError) fs.SetOutput(env.Err) - root := fs.String("root", "", "корень набора; по умолчанию ищется вверх от текущей директории") - quiet := fs.Bool("quiet", false, "печатать только находки") + root := fs.String("root", "", "root of the suite; by default it is looked up upwards from the current directory") + quiet := fs.Bool("quiet", false, "print findings only") if err := fs.Parse(args); err != nil { return Usage } @@ -27,9 +27,9 @@ func runSuiteCheck(env Env, args []string) ExitCode { found, err := manifest.Find(env.Dir) if err != nil { if errors.Is(err, manifest.ErrNotFound) { - fmt.Fprintf(env.Err, "здесь не набор конвенций: рядом и выше нет %s\n", manifest.Name) + fmt.Fprintf(env.Err, "not a conventions suite: no %s here or above\n", manifest.Name) if _, err := os.Stat(filepath.Join(env.Dir, ".conventions.toml")); err == nil { - fmt.Fprintln(env.Err, "это проект — проверка того, что здесь, называется \"convy check\"") + fmt.Fprintln(env.Err, "this is a project — checking what is here is called \"convy check\"") } return Usage } @@ -68,7 +68,7 @@ func printReport(w io.Writer, rep *check.Report, s *suite.Suite, quiet bool) { if f.Line > 0 { where = fmt.Sprintf(":%d", f.Line) } - fmt.Fprintf(w, " %s%s %s [%s]\n", label(f.Severity), where, f.Msg, f.Family) + fmt.Fprintf(w, " %s%s %s [%s]\n", f.Severity, where, f.Msg, f.Family) } if quiet { @@ -77,36 +77,24 @@ func printReport(w io.Writer, rep *check.Report, s *suite.Suite, quiet bool) { if len(findings) > 0 { fmt.Fprintln(w) } - fmt.Fprintf(w, "набор: %s, %s, версия языка %d (%s)\n", - plural(len(s.Docs), "файл", "файла", "файлов"), - plural(len(s.Manifest.LiveTopics()), "тема", "темы", "тем"), + fmt.Fprintf(w, "suite: %s, %s, language version %d (%s)\n", + plural(len(s.Docs), "file"), + plural(len(s.Manifest.LiveTopics()), "topic"), s.Manifest.Language.Version, s.Manifest.Language.Lang) switch { case rep.Errors() > 0: - fmt.Fprintf(w, "ошибок: %d, предупреждений: %d\n", rep.Errors(), rep.Warnings()) + fmt.Fprintf(w, "errors: %d, warnings: %d\n", rep.Errors(), rep.Warnings()) case rep.Warnings() > 0: - fmt.Fprintf(w, "ошибок нет, предупреждений: %d\n", rep.Warnings()) + fmt.Fprintf(w, "no errors, warnings: %d\n", rep.Warnings()) default: - fmt.Fprintln(w, "целостность набора в порядке") + fmt.Fprintln(w, "suite integrity holds") } } -// plural согласует существительное с числом: 1 файл, 2 файла, 5 файлов. -func plural(n int, one, few, many string) string { - word := many - switch { - case n%100 >= 11 && n%100 <= 14: - case n%10 == 1: - word = one - case n%10 >= 2 && n%10 <= 4: - word = few +// plural agrees a noun with a count: 1 file, 4 files. +func plural(n int, noun string) string { + if n == 1 { + return fmt.Sprintf("%d %s", n, noun) } - return fmt.Sprintf("%d %s", n, word) -} - -func label(s check.Severity) string { - if s == check.Warning { - return "предупреждение" - } - return "ошибка" + return fmt.Sprintf("%d %ss", n, noun) } diff --git a/internal/doc/doc.go b/internal/doc/doc.go index 0eae939..7025301 100644 --- a/internal/doc/doc.go +++ b/internal/doc/doc.go @@ -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 `### -. `; 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) } diff --git a/internal/doc/front.go b/internal/doc/front.go index 7027955..b748077 100644 --- a/internal/doc/front.go +++ b/internal/doc/front.go @@ -5,11 +5,13 @@ import ( "strings" ) -// Front — шапка файла. Ключей немного и все они плоские, поэтому разбор здесь -// свой: тащить YAML ради четырёх строк `ключ: значение` не за что. +// Front is the front matter of a file. There are few keys and all of them are +// flat, so the parsing is done here: pulling in YAML for four `key: value` +// lines earns nothing. // -// Ось слоя объявляется здесь ключами lang и stack, а не выводится из пути -// (META-38); отсутствие обоих означает базовый слой темы. +// The axis of a layer is declared here by the lang and stack keys rather than +// derived from the path (META-38); the absence of both means the base layer of +// the topic. type Front struct { Topic string Prefix string @@ -17,25 +19,26 @@ type Front struct { Stack string Extends string - // At — номер строки, на которой объявлен ключ; нужен, чтобы находка - // показывала на объявление, а не на начало файла. + // At is the line a key was declared on, so a finding can point at the + // declaration rather than at the top of the file. At map[string]int - // Unknown — ключи, которых модель не знает. + // Unknown lists keys the model does not know. Unknown []string - // End — номер строки закрывающего разделителя. Тело файла начинается - // со следующей. + // End is the line of the closing delimiter. The body of the file starts + // on the next one. End int - // Present — была ли шапка вообще. + // Present says whether there was any front matter at all. Present bool } -// Axis отвечает, объявлена ли у слоя ось. Слой без ключей оси — базовый. +// Axis reports whether the layer declares an axis. A layer without one is the +// base layer. func (f Front) Axis() bool { return f.Lang != "" || f.Stack != "" } -// parseFront разбирает шапку из строк файла. Возвращает шапку и номер первой -// строки тела. +// parseFront parses the front matter out of the file's lines. It returns the +// front matter and the number of the first line of the body. func parseFront(lines []string) (Front, int, error) { front := Front{At: make(map[string]int)} if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" { @@ -55,7 +58,7 @@ func parseFront(lines []string) (Front, int, error) { } key, value, ok := strings.Cut(line, ":") if !ok { - return front, num, fmt.Errorf("строка %d шапки не имеет вида «ключ: значение»", num) + return front, num, fmt.Errorf("front matter line %d is not of the form \"key: value\"", num) } key = strings.TrimSpace(key) value = strings.TrimSpace(value) @@ -76,5 +79,5 @@ func parseFront(lines []string) (Front, int, error) { front.Unknown = append(front.Unknown, key) } } - return front, len(lines) + 1, fmt.Errorf("шапка не закрыта разделителем ---") + return front, len(lines) + 1, fmt.Errorf("front matter is not closed by a --- delimiter") } diff --git a/internal/lang/vocabulary.go b/internal/lang/vocabulary.go index 38b52da..d923434 100644 --- a/internal/lang/vocabulary.go +++ b/internal/lang/vocabulary.go @@ -1,12 +1,14 @@ -// Package lang держит словари языка конвенций: слова, которыми записаны -// модальность правила, метки его блоков и связки сценарного блока. +// Package lang holds the vocabularies of the conventions language: the words +// that spell out a rule's modality, the marks of its blocks, and the +// connectives of a scenario block. // -// Словарь — свойство версии языка и естественного языка набора, а не самого -// набора: версия 1 по-русски задаёт один и тот же список слов в любом -// репозитории, и повторять его в каждом манифесте незачем. Пока спецификация -// языка живёт вместе с каноном, словари лежат здесь; когда она уедет в -// отдельный репозиторий со своими файлами словарей, источником станут они, а -// форма Vocabulary и все проверки поверх неё останутся прежними. +// A vocabulary belongs to a language version and to the suite's natural +// language, not to the suite itself: version 1 in Russian names the same words +// in every repository, and repeating that list in every manifest buys nothing. +// While the language specification lives together with the canon, the +// vocabularies live here; once it moves to its own repository with vocabulary +// files of its own, those become the source, and the Vocabulary type together +// with every check built on it stays as it is. package lang import ( @@ -17,9 +19,10 @@ import ( "unicode/utf8" ) -// Level — ступень шкалы обязательности. Ступеней пять в четырёх категориях -// ISO/IEC Directives, Part 2; какими словами они названы — параметр -// естественного языка, а сама шкала одна на все словари. +// Level is a step on the scale of obligation. There are five steps in the four +// categories of ISO/IEC Directives, Part 2; which words name them is a +// parameter of the natural language, while the scale is one for all +// vocabularies. type Level int const ( @@ -30,26 +33,26 @@ const ( Permission ) -// String даёт имя ступени для сообщений об ошибках — не слово словаря, а роль. +// String names the step for diagnostics — the role, not the vocabulary word. func (l Level) String() string { switch l { case Requirement: - return "требование" + return "requirement" case Prohibition: - return "запрет" + return "prohibition" case Recommendation: - return "рекомендация" + return "recommendation" case RecommendationAgainst: - return "рекомендация против" + return "recommendation against" case Permission: - return "разрешение" + return "permission" } - return "неизвестная ступень" + return "unknown level" } -// Mark — метка блока правила. Метки обязательности не задают, а размечают: -// что здесь обоснование, что иллюстрация, что запись о механизации, что -// заглушка на месте снятого правила. +// Mark labels a block of a rule. Marks set no obligation, they only say what +// this is: a rationale, an illustration, a note about mechanization, a stub in +// place of a retired rule. type Mark int const ( @@ -62,20 +65,20 @@ const ( func (m Mark) String() string { switch m { case Rationale: - return "обоснование" + return "rationale" case Examples: - return "примеры" + return "examples" case Mechanized: - return "механизация" + return "mechanized" case Retired: - return "снятое правило" + return "retired" } - return "неизвестная метка" + return "unknown mark" } -// Connective — служебное слово сценарного блока. В строку о версии языка эти -// слова не входят и под проверку «модальные слова вне правил» не подпадают: -// обязательности они не задают, только структуру. +// Connective is a service word of a scenario block. Such words stay out of the +// language version line and out of the "modal words outside rules" check: they +// set no obligation, only structure. type Connective int const ( @@ -85,7 +88,7 @@ const ( Or ) -// Vocabulary — словарь одной версии языка на одном естественном языке. +// Vocabulary is the vocabulary of one language version in one natural language. type Vocabulary struct { Version int Code string @@ -94,19 +97,19 @@ type Vocabulary struct { Scenario map[string]Connective } -// Modal сообщает ступень слова, если слово принадлежит шкале этого словаря. +// Modal reports the step of a word if the word belongs to this scale. func (v Vocabulary) Modal(word string) (Level, bool) { l, ok := v.Modals[word] return l, ok } -// Mark сообщает роль метки, если слово принадлежит меткам этого словаря. +// Mark reports the role of a mark if the word belongs to these marks. func (v Vocabulary) Mark(word string) (Mark, bool) { m, ok := v.Marks[word] return m, ok } -// Word возвращает слово, которым в этом словаре записана ступень. +// Word returns the word this vocabulary uses for a step. func (v Vocabulary) Word(l Level) string { for w, got := range v.Modals { if got == l { @@ -116,7 +119,7 @@ func (v Vocabulary) Word(l Level) string { return "" } -// MarkWord возвращает слово, которым в этом словаре записана метка. +// MarkWord returns the word this vocabulary uses for a mark. func (v Vocabulary) MarkWord(m Mark) string { for w, got := range v.Marks { if got == m { @@ -126,9 +129,9 @@ func (v Vocabulary) MarkWord(m Mark) string { return "" } -// Lead возвращает слово словаря, которым начинается text, и его длину. -// Длиннейшее совпадение выигрывает: «НЕ ДОЛЖЕН» не должен читаться как -// «ДОЛЖЕН», а метка с датой («СНЯТО 2026-07-26») — как метка без неё. +// Lead returns the vocabulary word that opens text. The longest match wins: +// "MUST NOT" must not be read as "MUST", and a mark carrying a date +// ("RETIRED 2026-07-26") must not be read as a different mark. func (v Vocabulary) Lead(text string) (string, bool) { best := "" for _, w := range v.Words() { @@ -143,8 +146,8 @@ func (v Vocabulary) Lead(text string) (string, bool) { return best, best != "" } -// Words перечисляет все слова словаря, которые язык объявляет в строке о -// версии: ступени шкалы и метки. Связки сценария сюда не входят. +// Words lists every word the language names in its version line: the steps of +// the scale and the marks. Scenario connectives are not among them. func (v Vocabulary) Words() []string { words := make([]string, 0, len(v.Modals)+len(v.Marks)) for w := range v.Modals { @@ -157,8 +160,8 @@ func (v Vocabulary) Words() []string { return words } -// hasWordPrefix проверяет, что text начинается со слова w и слово на этом -// кончается: «ДОЛЖЕНСТВОВАНИЕ» словом ДОЛЖЕН не является. +// hasWordPrefix reports whether text starts with word w and the word ends +// there: "MUSTARD" is not the word "MUST". func hasWordPrefix(text, w string) bool { if !strings.HasPrefix(text, w) { return false @@ -171,8 +174,8 @@ func hasWordPrefix(text, w string) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) } -// registry — словари, известные бинарю. Ключ верхнего уровня — версия языка, -// вложенный — код естественного языка набора. +// registry holds the vocabularies known to the binary. The outer key is the +// language version, the inner one the code of the suite's natural language. var registry = map[int]map[string]Vocabulary{ 1: { "ru": { @@ -224,21 +227,23 @@ var registry = map[int]map[string]Vocabulary{ }, } -// Lookup выдаёт словарь версии языка на указанном естественном языке. +// Lookup returns the vocabulary of a language version in the given natural +// language. func Lookup(version int, code string) (Vocabulary, error) { byCode, ok := registry[version] if !ok { - return Vocabulary{}, fmt.Errorf("версия языка %d инструменту неизвестна, известны: %s", version, versions()) + return Vocabulary{}, fmt.Errorf("language version %d is unknown to the tool; known versions: %s", version, versions()) } v, ok := byCode[code] if !ok { - return Vocabulary{}, fmt.Errorf("словарь %q для версии языка %d инструменту неизвестен, известны: %s", code, version, codes(version)) + return Vocabulary{}, fmt.Errorf("vocabulary %q of language version %d is unknown to the tool; known: %s", code, version, codes(version)) } return v, nil } -// Foreign перечисляет слова чужих словарей той же версии — те, по которым -// видно смесь словарей. Слова, совпадающие с собственными, отброшены. +// Foreign lists the words of the other vocabularies of the same version — the +// ones that give away a mixture of vocabularies. Words that coincide with the +// suite's own are dropped. func Foreign(version int, code string) map[string]string { own, err := Lookup(version, code) if err != nil { diff --git a/internal/lang/vocabulary_test.go b/internal/lang/vocabulary_test.go index 90172e2..52d3f75 100644 --- a/internal/lang/vocabulary_test.go +++ b/internal/lang/vocabulary_test.go @@ -31,12 +31,12 @@ func TestLeadTakesLongestMatch(t *testing.T) { got, ok := v.Lead(tc.text) if tc.want == "" { if ok { - t.Errorf("Lead(%q) = %q, ждали, что слово не найдётся", tc.text, got) + t.Errorf("Lead(%q) = %q, wanted no word to be found", tc.text, got) } continue } if !ok || got != tc.want { - t.Errorf("Lead(%q) = %q, %v; ждали %q", tc.text, got, ok, tc.want) + t.Errorf("Lead(%q) = %q, %v; wanted %q", tc.text, got, ok, tc.want) } } } @@ -44,30 +44,31 @@ func TestLeadTakesLongestMatch(t *testing.T) { func TestForeignExcludesOwnWords(t *testing.T) { foreign := lang.Foreign(1, "ru") if len(foreign) == 0 { - t.Fatal("для русского словаря не нашлось ни одного чужого слова") + t.Fatal("the Russian vocabulary yielded no foreign word at all") } if code, ok := foreign["MUST"]; !ok || code != "en" { - t.Errorf("MUST должно опознаваться как слово словаря en, получили %q, %v", code, ok) + t.Errorf("MUST should be recognized as a word of the en vocabulary, got %q, %v", code, ok) } + v, err := lang.Lookup(1, "ru") if err != nil { t.Fatal(err) } for word := range foreign { if _, own := v.Modal(word); own { - t.Errorf("слово %q собственное, а попало в чужие", word) + t.Errorf("the word %q is the suite's own, yet landed among the foreign ones", word) } if _, own := v.Mark(word); own { - t.Errorf("метка %q собственная, а попала в чужие", word) + t.Errorf("the mark %q is the suite's own, yet landed among the foreign ones", word) } } } func TestUnknownVersionAndCode(t *testing.T) { if _, err := lang.Lookup(99, "ru"); err == nil { - t.Error("неизвестная версия языка принята без ошибки") + t.Error("an unknown language version was accepted without an error") } if _, err := lang.Lookup(1, "xx"); err == nil { - t.Error("неизвестный словарь принят без ошибки") + t.Error("an unknown vocabulary was accepted without an error") } } diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go index 2057d5e..56b2f5c 100644 --- a/internal/manifest/manifest.go +++ b/internal/manifest/manifest.go @@ -1,9 +1,9 @@ -// Package manifest читает suite.toml — манифест набора конвенций. +// Package manifest reads suite.toml, the manifest of a conventions suite. // -// Манифест объявляет три вещи: язык, которым записаны правила набора, живые и -// выбывшие темы, живые и выбывшие префиксы правил вместе с путями к файлам. -// Инструмент не знает ни одной темы и ни одного префикса заранее — весь этот -// список приходит отсюда. +// The manifest declares three things: the language the suite's rules are +// written in, its live and retired topics, and its live and retired rule +// prefixes together with the paths of their files. The tool knows no topic and +// no prefix in advance — that whole list arrives from here. package manifest import ( @@ -17,17 +17,19 @@ import ( "github.com/BurntSushi/toml" ) -// Name — имя манифеста набора. По тому, какой из двух манифестов лежит рядом, -// определяется контекст: suite.toml — набор, .conventions.toml — проект. +// Name is the name of a suite manifest. Which of the two manifests lies next +// to you tells you where you are: suite.toml means a suite, .conventions.toml +// means a project. const Name = "suite.toml" -// DefaultLanguageCode — естественный язык набора, когда манифест о нём молчит. -// Ключ необязателен намеренно: словарь живёт в бинаре, и заставлять каждый -// набор объявлять то, что и так подразумевается, незачем. +// DefaultLanguageCode is the suite's natural language when the manifest says +// nothing about it. The key is optional on purpose: the vocabulary lives in the +// binary, and making every suite declare what is already implied buys nothing. const DefaultLanguageCode = "ru" -// Language — секция [language]: версия языка конвенций и два документа о нём. -// Полное описание остаётся у автора набора, короткое едет в копию. +// Language is the [language] section: the version of the conventions language +// and the two documents about it. The full description stays with the author of +// the suite, the short one travels into the copy. type Language struct { Version int `toml:"version"` Lang string `toml:"lang"` @@ -35,39 +37,40 @@ type Language struct { Reading string `toml:"reading"` } -// Section — раздел манифеста, разбитый на живую и выбывшую части. Выбывшее -// хранится, а не удаляется: имя темы и префикс правила живут в чужих -// репозиториях, и переиспользовать их нельзя никогда. +// Section is a part of the manifest split into a live and a retired half. +// Retired entries are kept rather than deleted: a topic name and a rule prefix +// live on in foreign repositories, and neither may ever be reused. type Section struct { Live map[string]string `toml:"live"` Retired map[string]string `toml:"retired"` } -// Manifest — разобранный suite.toml. +// Manifest is a parsed suite.toml. type Manifest struct { Language Language `toml:"language"` Topics Section `toml:"topics"` Prefixes Section `toml:"prefixes"` - // Path — путь, по которому манифест прочитан. + // Path is where the manifest was read from. Path string `toml:"-"` - // Undecoded — ключи, которых инструмент не знает. Опечатка в манифесте - // иначе прошла бы молча, а стоит она подписки или целого файла. + // Undecoded lists keys the tool does not know. A typo in the manifest + // would otherwise pass in silence, and it costs a subscription or a + // whole file. Undecoded []string `toml:"-"` } -// Load читает манифест набора из директории root. +// Load reads the suite manifest from directory root. func Load(root string) (*Manifest, error) { path := filepath.Join(root, Name) data, err := os.ReadFile(path) if err != nil { - return nil, fmt.Errorf("чтение манифеста набора: %w", err) + return nil, fmt.Errorf("reading the suite manifest: %w", err) } var m Manifest meta, err := toml.Decode(string(data), &m) if err != nil { - return nil, fmt.Errorf("разбор %s: %w", path, err) + return nil, fmt.Errorf("parsing %s: %w", path, err) } m.Path = path for _, key := range meta.Undecoded() { @@ -81,8 +84,8 @@ func Load(root string) (*Manifest, error) { return &m, nil } -// Find поднимается от start вверх до корня, ища директорию с манифестом -// набора. Так `convy suite check` работает из любой поддиректории набора. +// Find walks up from start looking for a directory that holds a suite +// manifest, so that `convy suite check` works from any subdirectory of a suite. func Find(start string) (string, error) { dir, err := filepath.Abs(start) if err != nil { @@ -100,22 +103,22 @@ func Find(start string) (string, error) { } } -// ErrNotFound означает, что рядом и выше нет манифеста набора. -var ErrNotFound = errors.New("манифест набора не найден") +// ErrNotFound means there is no suite manifest here or above. +var ErrNotFound = errors.New("suite manifest not found") -// LivePrefixes перечисляет живые префиксы в порядке, устойчивом между -// запусками: вывод проверки не должен зависеть от обхода карты. +// LivePrefixes lists the live prefixes in an order stable between runs: the +// output of a check must not depend on map iteration. func (m *Manifest) LivePrefixes() []string { return sortedKeys(m.Prefixes.Live) } -// LiveTopics перечисляет живые темы в устойчивом порядке. +// LiveTopics lists the live topics in a stable order. func (m *Manifest) LiveTopics() []string { return sortedKeys(m.Topics.Live) } -// PrefixOf возвращает префикс, объявленный за файлом, если такой есть. -// Путь сверяется в форме со слэшами — так он записан в манифесте. +// PrefixOf returns the prefix declared for a file, if there is one. Paths are +// compared in slash form, the way the manifest writes them. func (m *Manifest) PrefixOf(path string) (string, bool) { want := filepath.ToSlash(path) for prefix, declared := range m.Prefixes.Live { @@ -126,44 +129,44 @@ func (m *Manifest) PrefixOf(path string) (string, bool) { return "", false } -// PathOf возвращает путь, объявленный за живым префиксом. +// PathOf returns the path declared for a live prefix. func (m *Manifest) PathOf(prefix string) (string, bool) { path, ok := m.Prefixes.Live[prefix] return path, ok } -// TopicLive отвечает, объявлена ли тема среди живых. +// TopicLive reports whether the topic is declared among the live ones. func (m *Manifest) TopicLive(topic string) bool { _, ok := m.Topics.Live[topic] return ok } -// TopicRetired отвечает, значится ли тема среди выбывших. +// TopicRetired reports whether the topic is listed among the retired ones. func (m *Manifest) TopicRetired(topic string) bool { _, ok := m.Topics.Retired[topic] return ok } -// PrefixRetired отвечает, значится ли префикс среди выбывших. +// PrefixRetired reports whether the prefix is listed among the retired ones. func (m *Manifest) PrefixRetired(prefix string) bool { _, ok := m.Prefixes.Retired[prefix] return ok } -// ValidPrefix проверяет форму префикса: четыре заглавные латинские буквы. -// Буква X в начале зарезервирована за репозиториями-потребителями, и набор -// её не занимает никогда. +// ValidPrefix checks the shape of a prefix: four uppercase Latin letters. The +// letter X in first position is reserved for consuming repositories, and the +// suite never takes it. func ValidPrefix(prefix string) error { if len(prefix) != 4 { - return fmt.Errorf("префикс %q — не четыре буквы", prefix) + return fmt.Errorf("prefix %q is not four letters", prefix) } for _, r := range prefix { if r < 'A' || r > 'Z' { - return fmt.Errorf("префикс %q содержит не заглавную латинскую букву", prefix) + return fmt.Errorf("prefix %q holds a character that is not an uppercase Latin letter", prefix) } } if strings.HasPrefix(prefix, "X") { - return fmt.Errorf("префикс %q начинается на X — буква зарезервирована за локальными правилами потребителей", prefix) + return fmt.Errorf("prefix %q starts with X, a letter reserved for the local rules of consumers", prefix) } return nil } diff --git a/internal/suite/suite.go b/internal/suite/suite.go index df96573..4ce9904 100644 --- a/internal/suite/suite.go +++ b/internal/suite/suite.go @@ -1,11 +1,12 @@ -// Package suite собирает набор конвенций в память: манифест, словарь языка и -// документы, которые язык употребляет. +// Package suite assembles a conventions suite in memory: its manifest, the +// vocabulary of its language, and the documents the language employs. // -// Проверке подлежит всё, что язык употребляет: файлы конвенций и документ, -// которым набор ведёт себя сам. Список этих файлов даёт манифест — раздел -// живых префиксов, где у каждого префикса записан путь. Файл, который язык -// только цитирует (описание языка), в наборе не значится и проверок не -// получает. +// What is subject to checking is everything the language employs: the +// convention files and the document the suite governs itself by. The list of +// those files comes from the manifest, from the section of live prefixes where +// every prefix carries a path. A file the language merely quotes — the +// description of the language itself — is not listed in the suite and gets no +// checks. package suite import ( @@ -22,28 +23,28 @@ import ( "git.vakhrushev.me/av/convy/internal/manifest" ) -// Suite — загруженный набор. +// Suite is a loaded suite. type Suite struct { Root string Manifest *manifest.Manifest Vocab lang.Vocabulary - // Docs — документы набора в порядке живых префиксов. + // Docs holds the documents of the suite in the order of live prefixes. Docs []*doc.Document - // ByPrefix — документ по префиксу из манифеста. + // ByPrefix maps a manifest prefix to its document. ByPrefix map[string]*doc.Document - // Missing — префиксы, чей файл манифест объявляет, а файловой системы в - // нём нет. + // Missing lists the prefixes whose file the manifest declares while the + // file system holds none. Missing map[string]string - // Unregistered — найденные в наборе файлы с шапкой, которых манифест не - // объявляет. Такой файл не проверяется и не собирается: для набора его - // нет, хотя автор считает иначе. + // Unregistered lists files found in the suite carrying front matter that + // the manifest does not declare. Such a file is neither checked nor + // assembled: for the suite it does not exist, however its author sees it. Unregistered []string - // Broken — файлы, чью шапку не удалось разобрать. + // Broken lists files whose front matter could not be parsed. Broken []error } -// Load читает набор из директории root. +// Load reads a suite from directory root. func Load(root string) (*Suite, error) { m, err := manifest.Load(root) if err != nil { @@ -85,9 +86,10 @@ func Load(root string) (*Suite, error) { return s, nil } -// findUnregistered обходит набор и ищет файлы, записанные языком конвенций, но -// не объявленные в манифесте. Признак — ключ prefix в шапке, а не -// расположение файла: таксономию набор перестраивает, а шапка утверждает. +// findUnregistered walks the suite looking for files written in the conventions +// language yet absent from the manifest. The marker is the prefix key in the +// front matter rather than the location of the file: the suite rearranges its +// taxonomy, while the front matter asserts. func (s *Suite) findUnregistered() error { declared := make(map[string]bool) for _, path := range s.Manifest.Prefixes.Live { @@ -120,8 +122,9 @@ func (s *Suite) findUnregistered() error { } d, err := doc.Load(rel, name) if err != nil { - // Шапки у файла нет или она сломана — для набора это не - // документ языка, а просто markdown рядом. + // The file carries no front matter, or it is broken — for the + // suite this is not a document of the language but plain + // markdown lying nearby. return nil } if d.Front.Prefix != "" { @@ -130,15 +133,15 @@ func (s *Suite) findUnregistered() error { return nil }) if err != nil { - return fmt.Errorf("обход набора: %w", err) + return fmt.Errorf("walking the suite: %w", err) } sort.Strings(s.Unregistered) return nil } -// Conventions отбирает документы конвенций — те, что несут тему и потому -// уезжают к потребителю. Документ без темы (набор ведёт им себя сам) проверки -// распространения не получает. +// Conventions picks out the convention documents — the ones that carry a topic +// and therefore travel to a consumer. A document without a topic, the one the +// suite governs itself by, gets no spread checks. func (s *Suite) Conventions() []*doc.Document { var out []*doc.Document for _, d := range s.Docs { @@ -149,7 +152,8 @@ func (s *Suite) Conventions() []*doc.Document { return out } -// Layers перечисляет слои темы — документы, объявившие это имя в шапке. +// Layers lists the layers of a topic — the documents that declared that name in +// their front matter. func (s *Suite) Layers(topic string) []*doc.Document { var out []*doc.Document for _, d := range s.Docs { @@ -160,13 +164,13 @@ func (s *Suite) Layers(topic string) []*doc.Document { return out } -// Prefix возвращает префикс, объявленный манифестом за документом. +// Prefix returns the prefix the manifest declares for a document. func (s *Suite) Prefix(d *doc.Document) string { prefix, _ := s.Manifest.PrefixOf(d.Path) return prefix } -// Exists проверяет, есть ли в наборе файл по пути от корня. +// Exists reports whether the suite holds a file at a path from its root. func (s *Suite) Exists(rel string) bool { _, err := os.Stat(filepath.Join(s.Root, filepath.FromSlash(rel))) return err == nil diff --git a/main.go b/main.go index 9743e61..4eea645 100644 --- a/main.go +++ b/main.go @@ -1,5 +1,5 @@ -// Команда convy — управление конвенциями разработки: проверка целостности -// набора и сборка копий в проектах. +// Command convy tends development conventions: it checks the integrity of a +// suite and assembles copies inside projects. package main import (