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

- комментарии, тексты ошибок, вывод CLI и сообщения тестов теперь на английском
- по-русски остались только литералы словаря ru и содержимое фикстур: это
  данные под проверкой, а не текст инструмента
- согласование числительных в итоге упростилось до английского plural
This commit is contained in:
av
2026-07-27 10:04:27 +03:00
parent 0b8cc125b3
commit b2d07ae55d
17 changed files with 548 additions and 524 deletions
+74 -67
View File
@@ -10,8 +10,13 @@ import (
"git.vakhrushev.me/av/convy/internal/suite" "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 = `Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки const versionLine = `Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.` конвенций версии 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 type files map[string]string
func base() files { 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 { func run(t *testing.T, f files) []check.Finding {
t.Helper() t.Helper()
root := t.TempDir() root := t.TempDir()
@@ -84,7 +90,7 @@ func run(t *testing.T, f files) []check.Finding {
} }
s, err := suite.Load(root) s, err := suite.Load(root)
if err != nil { if err != nil {
t.Fatalf("загрузка набора: %v", err) t.Fatalf("loading the suite: %v", err)
} }
return check.Suite(s).Findings() return check.Suite(s).Findings()
} }
@@ -102,25 +108,26 @@ func messages(findings []check.Finding) string {
func TestCleanSuite(t *testing.T) { func TestCleanSuite(t *testing.T) {
if got := run(t, base()); len(got) != 0 { 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) { func TestRuleHeadingIsNotAReference(t *testing.T) {
f := base() f := base()
f["conventions/time.md"] = strings.Replace(baseTime, "### TIME-1.", "### GTIM-1.", 1) f["conventions/time.md"] = strings.Replace(baseTime, "### TIME-1.", "### GTIM-1.", 1)
for _, got := range run(t, f) { for _, got := range run(t, f) {
if got.Family == check.Links { 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 { func rule(id, title, norm, rationale string) string {
return "\n### " + id + ". " + title + "\n\n**ДОЛЖЕН.** " + norm + "\n\n**ПОЧЕМУ.** " + rationale + "\n" return "\n### " + id + ". " + title + "\n\n**ДОЛЖЕН.** " + norm + "\n\n**ПОЧЕМУ.** " + rationale + "\n"
} }
@@ -131,191 +138,191 @@ func TestChecks(t *testing.T) {
setup func(files) setup func(files)
want string want string
}{{ }{{
name: "префикс в шапке расходится с манифестом", name: "front matter prefix diverges from the manifest",
setup: func(f files) { setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, "prefix: TIME", "prefix: GTIM", 1) 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) { setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, "### TIME-1.", "### GTIM-1.", 1) 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) { setup: func(f files) {
f["conventions/time.md"] = baseTime + rule("TIME-3", "Третье", "Норма.", "Причина.") 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) { setup: func(f files) {
f["conventions/time.md"] = baseTime + rule("TIME-1", "Ещё раз первое", "Норма.", "Причина.") 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) { setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, f["conventions/time.md"] = strings.Replace(baseTime,
"**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.", "", 1) "**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.", "", 1)
}, },
want: "нет блока ПОЧЕМУ: обоснование обязательно", want: "has no ПОЧЕМУ block: the rationale is mandatory",
}, { }, {
name: "правило без нормы и без заглушки", name: "rule with neither a norm nor a stub",
setup: func(f files) { setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, f["conventions/time.md"] = strings.Replace(baseTime,
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.", "Просто текст.", 1) "**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.", "Просто текст.", 1)
}, },
want: "нет ни блока нормы, ни заглушки СНЯТО", want: "has neither a norm block nor a СНЯТО stub",
}, { }, {
name: "две нормы под одним номером", name: "two norms under one number",
setup: func(f files) { setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, f["conventions/time.md"] = strings.Replace(baseTime,
"**ПОЧЕМУ.**", "**СЛЕДУЕТ.** Вторая норма.\n\n**ПОЧЕМУ.**", 1) "**ПОЧЕМУ.**", "**СЛЕДУЕТ.** Вторая норма.\n\n**ПОЧЕМУ.**", 1)
}, },
want: "две нормы (ДОЛЖЕН и СЛЕДУЕТ)", want: "holds two norms (ДОЛЖЕН and СЛЕДУЕТ)",
}, { }, {
name: "обоснование стоит раньше нормы", name: "rationale precedes the norm",
setup: func(f files) { setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, f["conventions/time.md"] = strings.Replace(baseTime,
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.\n\n**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.", "**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.\n\n**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.",
"**ПОЧЕМУ.** Причина вперёд.\n\n**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.", 1) "**ПОЧЕМУ.** Причина вперёд.\n\n**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.", 1)
}, },
want: "обоснование стоит раньше нормы", want: "the rationale precedes the norm",
}, { }, {
name: "примеры стоят раньше обоснования", name: "examples precede the rationale",
setup: func(f files) { setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, f["conventions/time.md"] = strings.Replace(baseTime,
"**ПОЧЕМУ.**", "**ПРИМЕРЫ.** Иллюстрация.\n\n**ПОЧЕМУ.**", 1) "**ПОЧЕМУ.**", "**ПРИМЕРЫ.** Иллюстрация.\n\n**ПОЧЕМУ.**", 1)
}, },
want: "блок ПРИМЕРЫ стоит раньше обоснования", want: "the ПРИМЕРЫ block precedes the rationale",
}, { }, {
name: "заглушка снятого без даты", name: "stub of a retired rule without a date",
setup: func(f files) { setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, f["conventions/time.md"] = strings.Replace(baseTime,
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.\n\n**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.", "**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.\n\n**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.",
"**СНЯТО.** Правило убрано за ненадобностью.", 1) "**СНЯТО.** Правило убрано за ненадобностью.", 1)
}, },
want: "не несёт даты снятия", want: "carries no date of retirement",
}, { }, {
name: "у снятого правила осталась норма", name: "retired rule still holds a norm",
setup: func(f files) { setup: func(f files) {
f["conventions/time.md"] = baseTime + f["conventions/time.md"] = baseTime +
"\n### TIME-2. Снятое\n\n**СНЯТО 2026-07-26.** Причина снятия.\n\n**ДОЛЖЕН.** Остаток нормы.\n" "\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) { setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, versionLine, "Просто вводная проза.", 1) 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) { setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, "конвенций версии 1", "конвенций версии 2", 1) 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) { setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, f["conventions/time.md"] = strings.Replace(baseTime,
"Как приложение записывает моменты.", "Приложение ДОЛЖЕН писать моменты.", 1) "Как приложение записывает моменты.", "Приложение ДОЛЖЕН писать моменты.", 1)
}, },
want: "стоит вне области правила", want: "stands outside a rule area",
}, { }, {
name: "слово чужого словаря", name: "word of a foreign vocabulary",
setup: func(f files) { setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, f["conventions/time.md"] = strings.Replace(baseTime,
"**ПОЧЕМУ.** Без явного", "**ПОЧЕМУ.** Здесь MUST не к месту. Без явного", 1) "**ПОЧЕМУ.** Без явного", "**ПОЧЕМУ.** Здесь 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) { setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, f["conventions/time.md"] = strings.Replace(baseTime,
"Без явного смещения", "Смотри TIME-9. Без явного смещения", 1) "Без явного смещения", "Смотри TIME-9. Без явного смещения", 1)
}, },
want: "ссылка TIME-9 не разрешается", want: "reference TIME-9 does not resolve",
}, { }, {
name: "ссылка на неизвестный префикс", name: "reference to an unknown prefix",
setup: func(f files) { setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, f["conventions/time.md"] = strings.Replace(baseTime,
"Без явного смещения", "Смотри ZZZZ-1. Без явного смещения", 1) "Без явного смещения", "Смотри 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) { setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, "topic: time", "topic: clocks", 1) 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) { setup: func(f files) {
f["suite.toml"] = strings.Replace(baseManifest, f["suite.toml"] = strings.Replace(baseManifest,
"[topics.retired]", `[topics.retired]`+"\ntime = \"снята 2026-07-01\"", 1) "[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) { setup: func(f files) {
f["suite.toml"] = strings.Replace(baseManifest, f["suite.toml"] = strings.Replace(baseManifest,
`time = "время: хранение, зоны, форматы"`, `time = "время: хранение, зоны, форматы"`,
`time = "время"`+"\nlogging = \"логирование\"", 1) `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) { setup: func(f files) {
f["suite.toml"] = strings.Replace(baseManifest, f["suite.toml"] = strings.Replace(baseManifest,
`TIME = "conventions/time.md"`, `TIME = "conventions/time.md"`,
`TIME = "conventions/time.md"`+"\nSLOG = \"conventions/logging.md\"", 1) `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) { setup: func(f files) {
f["conventions/logging.md"] = "---\ntopic: logging\nprefix: SLOG\n---\n\n# Логирование\n" 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) { setup: func(f files) {
f["suite.toml"] = strings.Replace(baseManifest, f["suite.toml"] = strings.Replace(baseManifest,
`TIME = "conventions/time.md"`, `XTIM = "conventions/time.md"`, 1) `TIME = "conventions/time.md"`, `XTIM = "conventions/time.md"`, 1)
f["conventions/time.md"] = strings.ReplaceAll(baseTime, "TIME", "XTIM") 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) { setup: func(f files) {
f["suite.toml"] = strings.Replace(baseManifest, f["suite.toml"] = strings.Replace(baseManifest,
`TIME = "conventions/time.md"`, `TIME = "conventions/time.md"`,
`TIME = "conventions/time.md"`+"\nGTIM = \"conventions/time.md\"", 1) `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) { setup: func(f files) {
f["suite.toml"] = baseManifest + "\n[extra]\nkey = 1\n" 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) { setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, f["conventions/time.md"] = strings.Replace(baseTime,
"Без явного смещения", "Правило МЕХАНИЗИРОВАНО линтером. Без явного смещения", 1) "Без явного смещения", "Правило МЕХАНИЗИРОВАНО линтером. Без явного смещения", 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) { setup: func(f files) {
f["conventions/time.md"] = strings.Replace(baseTime, f["conventions/time.md"] = strings.Replace(baseTime,
"Без явного смещения", "Смотри conventions/time.md. Без явного смещения", 1) "Без явного смещения", "Смотри conventions/time.md. Без явного смещения", 1)
}, },
want: "стоит путь файла канона", want: "the text holds the canon file path",
}} }}
for _, tc := range cases { for _, tc := range cases {
@@ -324,7 +331,7 @@ func TestChecks(t *testing.T) {
tc.setup(f) tc.setup(f)
got := messages(run(t, f)) got := messages(run(t, f))
if !strings.Contains(got, tc.want) { 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)
} }
}) })
} }
+54 -52
View File
@@ -12,9 +12,9 @@ import (
"git.vakhrushev.me/av/convy/internal/suite" "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) { func checkForm(s *suite.Suite, d *doc.Document, rep *Report) {
prefix := checkFilePrefix(s, d, rep) prefix := checkFilePrefix(s, d, rep)
checkHeadings(d, prefix, rep) checkHeadings(d, prefix, rep)
@@ -25,47 +25,47 @@ func checkForm(s *suite.Suite, d *doc.Document, rep *Report) {
checkForeignVocabulary(s, d, rep) 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 { func checkFilePrefix(s *suite.Suite, d *doc.Document, rep *Report) string {
declared, _ := s.Manifest.PrefixOf(d.Path) declared, _ := s.Manifest.PrefixOf(d.Path)
if !d.Front.Present { 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 return declared
} }
if d.Front.Prefix == "" { 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 return declared
} }
at := d.Front.At["prefix"] at := d.Front.At["prefix"]
if d.Front.Prefix != declared { if d.Front.Prefix != declared {
rep.Errorf(Form, d.Path, at, 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 { if err := manifest.ValidPrefix(d.Front.Prefix); err != nil {
rep.Errorf(Form, d.Path, at, "%s", err) rep.Errorf(Form, d.Path, at, "%s", err)
} }
if s.Manifest.PrefixRetired(d.Front.Prefix) { 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 { 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 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) { func checkHeadings(d *doc.Document, prefix string, rep *Report) {
for _, r := range d.Rules { for _, r := range d.Rules {
if r.Prefix != prefix { if r.Prefix != prefix {
rep.Errorf(Form, d.Path, r.Line, 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 { if r.HeadingLevel != 3 {
rep.Errorf(Form, d.Path, r.Line, 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 != "" { if r.Malformed != "" {
rep.Errorf(Form, d.Path, r.Line, "%s: %s", r.ID(), 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 проверяет сплошную нумерацию: от единицы до наибольшего без // checkNumbering checks that numbering is contiguous: from one up to the
// пропусков и без повторов (META-31). Дыра неотличима от опечатки в номере и // 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) { func checkNumbering(d *doc.Document, prefix string, rep *Report) {
seen := make(map[int][]int) seen := make(map[int][]int)
for _, r := range d.Rules { for _, r := range d.Rules {
@@ -100,7 +100,7 @@ func checkNumbering(d *doc.Document, prefix string, rep *Report) {
for _, n := range nums { for _, n := range nums {
if lines := seen[n]; len(lines) > 1 { if lines := seen[n]; len(lines) > 1 {
rep.Errorf(Form, d.Path, 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 var gaps []int
@@ -111,15 +111,15 @@ func checkNumbering(d *doc.Document, prefix string, rep *Report) {
} }
if len(gaps) > 0 { if len(gaps) > 0 {
rep.Errorf(Form, d.Path, d.Rules[0].Line, 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)) highest, joinInts(gaps))
} }
} }
var dateRe = regexp.MustCompile(`\d{4}-\d{2}-\d{2}`) 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). // (META-8, META-10).
func checkRules(s *suite.Suite, d *doc.Document, rep *Report) { func checkRules(s *suite.Suite, d *doc.Document, rep *Report) {
v := s.Vocab v := s.Vocab
@@ -133,25 +133,25 @@ func checkRules(s *suite.Suite, d *doc.Document, rep *Report) {
switch len(norms) { switch len(norms) {
case 0: case 0:
rep.Errorf(Form, d.Path, r.Line, 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: case 1:
if norms[0].Rest == "" { if norms[0].Rest == "" {
rep.Errorf(Form, d.Path, norms[0].Start, 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: default:
rep.Errorf(Form, d.Path, norms[1].Start, 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) r.ID(), norms[0].Word, norms[1].Word)
} }
rationale, ok := r.Block(lang.Rationale) rationale, ok := r.Block(lang.Rationale)
if !ok { if !ok {
rep.Errorf(Form, d.Path, r.Line, 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 { } else if len(norms) > 0 && rationale.Start < norms[0].Start {
rep.Errorf(Form, d.Path, rationale.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)) 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 { switch {
case len(r.Blocks) > 0 && r.Blocks[0].Start == examples.Start: case len(r.Blocks) > 0 && r.Blocks[0].Start == examples.Start:
rep.Errorf(Form, d.Path, 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)) r.ID(), v.MarkWord(lang.Examples), v.MarkWord(lang.Rationale), v.MarkWord(lang.Examples))
case ok && rationale.Start > examples.Start: case ok && rationale.Start > examples.Start:
rep.Errorf(Form, d.Path, 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)) 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) { func checkRetired(d *doc.Document, r doc.Rule, retired doc.Block, rep *Report) {
if len(r.Norms()) > 0 { if len(r.Norms()) > 0 {
rep.Errorf(Form, d.Path, retired.Start, 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)) { if !dateRe.MatchString(d.Line(retired.Start)) {
rep.Errorf(Form, d.Path, 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) == "" { if strings.TrimSpace(retired.Rest) == "" {
rep.Errorf(Form, d.Path, retired.Start, 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) { func checkVersionLine(s *suite.Suite, d *doc.Document, rep *Report) (from, to int) {
p, ok := versionParagraph(s, d) p, ok := versionParagraph(s, d)
if !ok { if !ok {
start, _ := d.Preamble() start, _ := d.Preamble()
rep.Errorf(Form, d.Path, start, 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 return 0, 0
} }
version := strconv.Itoa(s.Manifest.Language.Version) version := strconv.Itoa(s.Manifest.Language.Version)
if !containsNumber(p.Text(), version) { if !containsNumber(p.Text(), version) {
rep.Errorf(Form, d.Path, p.Start, 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 return p.Start, p.End
} }
// versionParagraph ищет во вводной прозе абзац, несущий строку о версии языка: // versionParagraph looks in the introductory prose for the paragraph carrying
// тот, где перечислены все ключевые слова набора. Ничего не сообщает — о его // the language version line: the one listing every key word of the suite. It
// отсутствии говорит checkVersionLine, и говорить дважды незачем. // reports nothing — checkVersionLine speaks about its absence, and speaking
// twice helps no one.
func versionParagraph(s *suite.Suite, d *doc.Document) (doc.Paragraph, bool) { func versionParagraph(s *suite.Suite, d *doc.Document) (doc.Paragraph, bool) {
from, to := d.Preamble() from, to := d.Preamble()
words := s.Vocab.Words() words := s.Vocab.Words()
@@ -221,9 +223,9 @@ func versionParagraph(s *suite.Suite, d *doc.Document) (doc.Paragraph, bool) {
return doc.Paragraph{}, false 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) { func checkModalsOutside(s *suite.Suite, d *doc.Document, versionFrom, versionTo int, rep *Report) {
words := modalWords(s.Vocab) words := modalWords(s.Vocab)
d.Prose(func(n int, text string) bool { d.Prose(func(n int, text string) bool {
@@ -235,16 +237,16 @@ func checkModalsOutside(s *suite.Suite, d *doc.Document, versionFrom, versionTo
continue continue
} }
rep.Errorf(Form, d.Path, n, 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 break
} }
return true 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) { func checkForeignVocabulary(s *suite.Suite, d *doc.Document, rep *Report) {
foreign := lang.Foreign(s.Manifest.Language.Version, s.Manifest.Language.Lang) foreign := lang.Foreign(s.Manifest.Language.Version, s.Manifest.Language.Lang)
if len(foreign) == 0 { if len(foreign) == 0 {
@@ -260,7 +262,7 @@ func checkForeignVocabulary(s *suite.Suite, d *doc.Document, rep *Report) {
for _, w := range words { for _, w := range words {
if containsWord(text, w) { if containsWord(text, w) {
rep.Errorf(Form, d.Path, n, 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) w, foreign[w], s.Manifest.Language.Lang)
} }
} }
@@ -286,8 +288,8 @@ func containsAll(text string, words []string) bool {
return true 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 { func containsWord(text, word string) bool {
for i := 0; ; { for i := 0; ; {
j := strings.Index(text[i:], word) j := strings.Index(text[i:], word)
+27 -27
View File
@@ -5,9 +5,9 @@ import (
"testing" "testing"
) )
// Набор с двумя слоями одной темы: базовый арх-слой и языковой поверх него. // A suite with two layers of one topic: a base architectural layer and a
// На нём проверяется всё, что про оси, extends и границу самодостаточности // 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 = ` const layeredManifest = `
[language] [language]
@@ -104,7 +104,7 @@ func layered() files {
func TestLayeredSuiteIsClean(t *testing.T) { func TestLayeredSuiteIsClean(t *testing.T) {
if got := run(t, layered()); len(got) != 0 { 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) setup func(files)
want string want string
}{{ }{{
name: "ось в шапке расходится с путём", name: "axis in the front matter diverges from the path",
setup: func(f files) { setup: func(f files) {
f["conventions/lang/go/time.md"] = strings.Replace(goTime, "lang: go", "lang: python", 1) 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) { setup: func(f files) {
f["conventions/lang/go/time.md"] = strings.Replace(goTime, f["conventions/lang/go/time.md"] = strings.Replace(goTime,
"extends: arch/time.md", "extends: arch/logging.md", 1) "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) { setup: func(f files) {
f["conventions/lang/go/time.md"] = strings.Replace(goTime, f["conventions/lang/go/time.md"] = strings.Replace(goTime,
"extends: arch/time.md", "extends: arch/clocks.md", 1) "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) { setup: func(f files) {
f["suite.toml"] = strings.Replace(layeredManifest, f["suite.toml"] = strings.Replace(layeredManifest,
`GTIM = "conventions/lang/go/time.md"`, `GTIM = "conventions/lang/go/time.md"`,
@@ -144,23 +144,23 @@ func TestLayeredChecks(t *testing.T) {
strings.Replace(goTime, "lang: go\n", "", 1), strings.Replace(goTime, "lang: go\n", "", 1),
"extends: arch/time.md\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) { setup: func(f files) {
f["conventions/lang/go/time.md"] = strings.Replace(goTime, f["conventions/lang/go/time.md"] = strings.Replace(goTime,
"**ДОЛЖЕН.** Текущее время приходит из store.Now().", "**ДОЛЖЕН.** Текущее время приходит из store.Now().",
"**ДОЛЖЕН.** Текущее время приходит из store.Now() и пишется по SLOG-1.", 1) "**ДОЛЖЕН.** Текущее время приходит из 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) { setup: func(f files) {
f["conventions/arch/time.md"] = strings.Replace(archTime, f["conventions/arch/time.md"] = strings.Replace(archTime,
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.", "**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.",
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z, как требует GTIM-1.", 1) "**ДОЛЖЕН.** Момент времени записывается с суффиксом Z, как требует GTIM-1.", 1)
}, },
want: "слой своей темы, но не базовый", want: "a layer of its own topic but not the base one",
}} }}
for _, tc := range cases { for _, tc := range cases {
@@ -169,55 +169,55 @@ func TestLayeredChecks(t *testing.T) {
tc.setup(f) tc.setup(f)
got := messages(run(t, f)) got := messages(run(t, f))
if !strings.Contains(got, tc.want) { 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) { func TestNoFalsePositives(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
setup func(files) setup func(files)
}{{ }{{
name: "идентификатор в бэктиках — образец записи, а не ссылка", name: "an identifier in backticks is a sample of notation, not a reference",
setup: func(f files) { setup: func(f files) {
f["conventions/arch/time.md"] = strings.Replace(archTime, f["conventions/arch/time.md"] = strings.Replace(archTime,
"Без явного смещения", "Без явного смещения",
"На правило ссылаются идентификатором (`TIME-99`). Без явного смещения", 1) "На правило ссылаются идентификатором (`TIME-99`). Без явного смещения", 1)
}, },
}, { }, {
name: "модальное слово внутри огороженного блока кода", name: "a modal word inside a fenced code block",
setup: func(f files) { setup: func(f files) {
f["conventions/arch/time.md"] = archTime + f["conventions/arch/time.md"] = archTime +
"\n## Связано\n\n```\nДОЛЖЕН это не норма, а строка примера\n```\n" "\n## Связано\n\n```\nДОЛЖЕН это не норма, а строка примера\n```\n"
}, },
}, { }, {
name: "заглавное SQL-слово в примере кода", name: "an uppercase SQL keyword in a code sample",
setup: func(f files) { setup: func(f files) {
f["conventions/arch/time.md"] = strings.Replace(archTime, f["conventions/arch/time.md"] = strings.Replace(archTime,
"**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.", "**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.",
"**ПОЧЕМУ.** Без явного смещения не видно зоны.\n\n```sql\nSELECT 1 WHERE a AND b OR c\n```", 1) "**ПОЧЕМУ.** Без явного смещения не видно зоны.\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) { setup: func(f files) {
f["conventions/lang/go/time.md"] = strings.Replace(goTime, f["conventions/lang/go/time.md"] = strings.Replace(goTime,
"**ДОЛЖЕН.** Текущее время приходит из store.Now().", "**ДОЛЖЕН.** Текущее время приходит из store.Now().",
"**ДОЛЖЕН.** Текущее время приходит из store.Now() в форме TIME-1.", 1) "**ДОЛЖЕН.** Текущее время приходит из store.Now() в форме TIME-1.", 1)
}, },
}, { }, {
name: "упоминание ступени в обосновании — не вторая норма", name: "a mention of a step in the rationale is not a second norm",
setup: func(f files) { setup: func(f files) {
f["conventions/arch/time.md"] = strings.Replace(archTime, f["conventions/arch/time.md"] = strings.Replace(archTime,
"**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.", "**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.",
"**ПОЧЕМУ.** Для ступени СЛЕДУЕТ это было бы честно, но здесь ломается сортировка.", 1) "**ПОЧЕМУ.** Для ступени СЛЕДУЕТ это было бы честно, но здесь ломается сортировка.", 1)
}, },
}, { }, {
name: "заглушка снятого правила с датой и причиной", name: "a stub of a retired rule with a date and a reason",
setup: func(f files) { setup: func(f files) {
f["conventions/arch/time.md"] = archTime + f["conventions/arch/time.md"] = archTime +
"\n### TIME-2. Ширина строки фиксируется\n\n**СНЯТО 2026-07-26.** Правило переехало в GTIM-1.\n" "\n### TIME-2. Ширина строки фиксируется\n\n**СНЯТО 2026-07-26.** Правило переехало в GTIM-1.\n"
@@ -229,7 +229,7 @@ func TestNoFalsePositives(t *testing.T) {
f := layered() f := layered()
tc.setup(f) tc.setup(f)
if got := run(t, f); len(got) != 0 { 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))
} }
}) })
} }
+23 -23
View File
@@ -9,11 +9,11 @@ import (
"git.vakhrushev.me/av/convy/internal/suite" "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+))?`) 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 { type Ref struct {
Prefix string Prefix string
Num int Num int
@@ -22,13 +22,13 @@ type Ref struct {
Text string Text string
} }
// refsIn собирает ссылки в диапазоне строк документа. Инлайн-код вырезан: в // 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
// иначе строка «на конкретное правило ссылаются идентификатором (`SLOG-27`)» // as a reference to an assertion — otherwise the line "a rule is referred to by
// требовала бы, чтобы правило SLOG-27 существовало. // 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 { func refsIn(d *doc.Document, from, to int) []Ref {
heading := make(map[int]bool, len(d.Rules)) heading := make(map[int]bool, len(d.Rules))
for _, r := range d.Rules { for _, r := range d.Rules {
@@ -60,48 +60,48 @@ func refsInLine(n int, text string) []Ref {
return out return out
} }
// checkLinks проверяет, что каждая ссылка разрешается. Неразрешённый // 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
// сняли (META-31, META-32). // why it was retired (META-31, META-32).
func checkLinks(s *suite.Suite, d *doc.Document, rep *Report) { func checkLinks(s *suite.Suite, d *doc.Document, rep *Report) {
for _, ref := range refsIn(d, d.Body, d.Len()) { for _, ref := range refsIn(d, d.Body, d.Len()) {
if strings.HasPrefix(ref.Prefix, "X") { 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 continue
} }
if s.Manifest.PrefixRetired(ref.Prefix) { if s.Manifest.PrefixRetired(ref.Prefix) {
rep.Errorf(Links, d.Path, ref.Line, 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 continue
} }
target, ok := s.ByPrefix[ref.Prefix] target, ok := s.ByPrefix[ref.Prefix]
if !ok { if !ok {
if _, declared := s.Manifest.PathOf(ref.Prefix); declared { 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 continue
} }
rep.Errorf(Links, d.Path, ref.Line, 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 continue
} }
rule, ok := ruleByNum(target, ref.Num) rule, ok := ruleByNum(target, ref.Num)
if !ok { if !ok {
rep.Errorf(Links, d.Path, ref.Line, 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 continue
} }
if ref.Sub > 0 && !mentions(target, rule, ref.Text) { if ref.Sub > 0 && !mentions(target, rule, ref.Text) {
rep.Errorf(Links, d.Path, ref.Line, 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 { func mentions(d *doc.Document, rule doc.Rule, text string) bool {
for n := rule.Line; n <= rule.End; n++ { for n := rule.Line; n <= rule.End; n++ {
if strings.Contains(d.Line(n), text) { if strings.Contains(d.Line(n), text) {
+26 -26
View File
@@ -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 package check
import ( import (
@@ -13,8 +13,8 @@ import (
"sort" "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 type Severity int
const ( const (
@@ -24,43 +24,44 @@ const (
func (s Severity) String() string { func (s Severity) String() string {
if s == Warning { if s == Warning {
return "предупреждение" return "warning"
} }
return "ошибка" return "error"
} }
// Family — семейство проверок, из которого пришла находка. // Family is the family of checks a finding came from.
type Family string type Family string
const ( const (
Manifest Family = "манифест" Manifest Family = "manifest"
Form Family = "форма" Form Family = "form"
Spread Family = "распространение" Spread Family = "spread"
Links Family = "ссылки" Links Family = "links"
) )
// Finding — одна находка. // Finding is a single finding.
type Finding struct { type Finding struct {
Severity Severity Severity Severity
Family Family 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 Path string
// Line — строка файла; ноль, если находка не привязана к строке. // Line is the line of the file; zero when the finding is not bound to one.
Line int Line int
Msg string Msg string
} }
// Report накапливает находки одного прогона. // Report accumulates the findings of one run.
type Report struct { type Report struct {
findings []Finding findings []Finding
} }
// Errorf записывает ошибку. // Errorf records an error.
func (r *Report) Errorf(f Family, path string, line int, format string, args ...any) { func (r *Report) Errorf(f Family, path string, line int, format string, args ...any) {
r.add(Error, f, path, line, format, args...) 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) { func (r *Report) Warnf(f Family, path string, line int, format string, args ...any) {
r.add(Warning, f, path, line, format, args...) 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 { func (r *Report) Findings() []Finding {
out := make([]Finding, len(r.findings)) out := make([]Finding, len(r.findings))
copy(out, r.findings) copy(out, r.findings)
@@ -89,7 +89,7 @@ func (r *Report) Findings() []Finding {
return out return out
} }
// Errors считает находки уровня ошибки. // Errors counts the findings of error severity.
func (r *Report) Errors() int { func (r *Report) Errors() int {
n := 0 n := 0
for _, f := range r.findings { for _, f := range r.findings {
@@ -100,7 +100,7 @@ func (r *Report) Errors() int {
return n return n
} }
// Warnings считает предупреждения. // Warnings counts the warnings.
func (r *Report) Warnings() int { func (r *Report) Warnings() int {
return len(r.findings) - r.Errors() return len(r.findings) - r.Errors()
} }
+42 -36
View File
@@ -10,9 +10,9 @@ import (
"git.vakhrushev.me/av/convy/internal/suite" "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) { func checkSpread(s *suite.Suite, d *doc.Document, rep *Report) {
checkTopic(s, d, rep) checkTopic(s, d, rep)
checkAxis(d, rep) checkAxis(d, rep)
@@ -22,23 +22,25 @@ func checkSpread(s *suite.Suite, d *doc.Document, rep *Report) {
checkForeignTopicPrefix(s, d, rep) 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) { func checkTopic(s *suite.Suite, d *doc.Document, rep *Report) {
topic := d.Front.Topic topic := d.Front.Topic
at := d.Front.At["topic"] at := d.Front.At["topic"]
switch { switch {
case s.Manifest.TopicRetired(topic): case s.Manifest.TopicRetired(topic):
rep.Errorf(Spread, d.Path, at, 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): case !s.Manifest.TopicLive(topic):
rep.Errorf(Spread, d.Path, at, rep.Errorf(Spread, d.Path, at,
"тема %q не объявлена в манифесте набора", topic) "topic %q is not declared in the suite manifest", topic)
} }
} }
// checkAxis сверяет объявленную ось с путём файла. Ось объявляется в шапке, а // checkAxis reconciles the declared axis with the path of the file. An axis is
// не выводится из пути (META-38); но если директории осей используются, // 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) { func checkAxis(d *doc.Document, rep *Report) {
parts := strings.Split(path.Dir(d.Path), "/") parts := strings.Split(path.Dir(d.Path), "/")
for i := 0; i+1 < len(parts); i++ { for i := 0; i+1 < len(parts); i++ {
@@ -59,13 +61,13 @@ func checkAxis(d *doc.Document, rep *Report) {
at = d.Front.At["prefix"] at = d.Front.At["prefix"]
} }
rep.Errorf(Spread, d.Path, at, 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) { func checkExtends(s *suite.Suite, d *doc.Document, rep *Report) {
if d.Front.Extends == "" { if d.Front.Extends == "" {
return return
@@ -74,22 +76,23 @@ func checkExtends(s *suite.Suite, d *doc.Document, rep *Report) {
target := resolveExtends(s, d.Front.Extends) target := resolveExtends(s, d.Front.Extends)
if target == nil { if target == nil {
rep.Errorf(Spread, d.Path, at, 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 return
} }
if target.Front.Topic != d.Front.Topic { if target.Front.Topic != d.Front.Topic {
rep.Errorf(Spread, d.Path, at, 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) d.Front.Extends, target.Front.Topic, d.Front.Topic)
} }
if target.Front.Axis() { if target.Front.Axis() {
rep.Errorf(Spread, d.Path, at, 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 { func resolveExtends(s *suite.Suite, ref string) *doc.Document {
ref = path.Clean(strings.TrimPrefix(ref, "./")) ref = path.Clean(strings.TrimPrefix(ref, "./"))
for _, d := range s.Docs { for _, d := range s.Docs {
@@ -100,9 +103,10 @@ func resolveExtends(s *suite.Suite, ref string) *doc.Document {
return nil return nil
} }
// checkMechanized ищет метку механизации в тексте конвенции. Механизирована // 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
// локальная часть копии (META-7). // 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) { func checkMechanized(s *suite.Suite, d *doc.Document, rep *Report) {
word := s.Vocab.MarkWord(lang.Mechanized) word := s.Vocab.MarkWord(lang.Mechanized)
if word == "" { if word == "" {
@@ -115,21 +119,22 @@ func checkMechanized(s *suite.Suite, d *doc.Document, rep *Report) {
} }
if containsWord(text, word) { if containsWord(text, word) {
rep.Errorf(Spread, d.Path, n, 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 return true
}) })
} }
// mdPathRe ловит то, что выглядит путём к файлу набора. // mdPathRe catches what looks like a path to a file of the suite.
var mdPathRe = regexp.MustCompile(`[\w./-]+\.md`) var mdPathRe = regexp.MustCompile(`[\w./-]+\.md`)
// checkCanonPaths ищет путь файла канона в тексте конвенции (META-21). В // 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,
// одного файла, и путь `lang/go/logging.md` там не существует: ссылка на него // 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) { func checkCanonPaths(s *suite.Suite, d *doc.Document, rep *Report) {
for n := d.Body; n <= d.Len(); n++ { for n := d.Body; n <= d.Len(); n++ {
if d.Fenced(n) { if d.Fenced(n) {
@@ -141,16 +146,17 @@ func checkCanonPaths(s *suite.Suite, d *doc.Document, rep *Report) {
continue continue
} }
rep.Errorf(Spread, d.Path, n, 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). // 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
// Префикс базового слоя своей темы там допустим (META-24) — собранный файл // 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) { func checkForeignTopicPrefix(s *suite.Suite, d *doc.Document, rep *Report) {
own := s.Prefix(d) own := s.Prefix(d)
for _, r := range d.Rules { 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 { if target.Front.Topic != d.Front.Topic {
rep.Errorf(Spread, d.Path, ref.Line, 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) r.ID(), ref.Text, target.Front.Topic)
continue continue
} }
if target.Front.Axis() { if target.Front.Axis() {
rep.Errorf(Spread, d.Path, ref.Line, 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) r.ID(), ref.Text)
} }
} }
+17 -16
View File
@@ -7,7 +7,7 @@ import (
"git.vakhrushev.me/av/convy/internal/suite" "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 { func Suite(s *suite.Suite) *Report {
rep := &Report{} rep := &Report{}
checkManifest(s, rep) checkManifest(s, rep)
@@ -23,17 +23,18 @@ func Suite(s *suite.Suite) *Report {
return rep 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) { func checkManifest(s *suite.Suite, rep *Report) {
m := s.Manifest m := s.Manifest
for _, key := range m.Undecoded { 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} { for _, name := range []string{m.Language.Description, m.Language.Reading} {
if name != "" && !s.Exists(name) { 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) { if m.PrefixRetired(prefix) {
rep.Errorf(Manifest, manifest.Name, 0, 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] path := m.Prefixes.Live[prefix]
byPath[path] = append(byPath[path], 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 { if prefixes := byPath[path]; len(prefixes) > 1 {
sort.Strings(prefixes) sort.Strings(prefixes)
rep.Errorf(Manifest, manifest.Name, 0, 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 { for prefix := range m.Prefixes.Retired {
if err := manifest.ValidPrefix(prefix); err != nil { 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) { for _, prefix := range sortedKeys(s.Missing) {
rep.Errorf(Manifest, manifest.Name, 0, 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 { for _, path := range s.Unregistered {
rep.Errorf(Manifest, path, 1, 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 { for _, err := range s.Broken {
rep.Errorf(Manifest, manifest.Name, 0, "%s", err) rep.Errorf(Manifest, manifest.Name, 0, "%s", err)
@@ -77,18 +78,18 @@ func checkManifest(s *suite.Suite, rep *Report) {
for _, topic := range m.LiveTopics() { for _, topic := range m.LiveTopics() {
if m.TopicRetired(topic) { if m.TopicRetired(topic) {
rep.Errorf(Manifest, manifest.Name, 0, rep.Errorf(Manifest, manifest.Name, 0,
"тема %q значится и среди живых, и среди выбывших", topic) "topic %q is listed both live and retired", topic)
} }
if len(s.Layers(topic)) == 0 { if len(s.Layers(topic)) == 0 {
rep.Errorf(Manifest, manifest.Name, 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) { func checkBaseLayers(s *suite.Suite, rep *Report) {
for _, topic := range s.Manifest.LiveTopics() { for _, topic := range s.Manifest.LiveTopics() {
var base []string var base []string
@@ -101,7 +102,7 @@ func checkBaseLayers(s *suite.Suite, rep *Report) {
sort.Strings(base) sort.Strings(base)
for _, path := range base[1:] { for _, path := range base[1:] {
rep.Errorf(Spread, path, 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) topic, base)
} }
} }
+3 -3
View File
@@ -5,8 +5,8 @@ import (
"unicode/utf8" "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 { func letterAt(text string, i int) bool {
if i >= len(text) { if i >= len(text) {
return false return false
@@ -15,7 +15,7 @@ func letterAt(text string, i int) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r) 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 { func letterBefore(text string, i int) bool {
if i <= 0 { if i <= 0 {
return false return false
+30 -30
View File
@@ -1,10 +1,10 @@
// Package cli раскладывает команды инструмента. // Package cli lays out the commands of the tool.
// //
// Глубина команды отражает частоту и адресата: проектные команды выполняются в // 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
// Поэтому `check` стоит наверху, а под `suite` уходит то, что в проекте не // one repository and rarely. That is why `check` stays at the top level and
// имеет смысла. Синонимов нет: `convy suite pull` рядом с `convy pull` не // 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 package cli
import ( import (
@@ -13,19 +13,20 @@ import (
"os" "os"
) )
// ExitCode — код возврата процесса. // ExitCode is the exit status of the process.
type ExitCode int type ExitCode int
const ( const (
// OK — проверка прошла, работа сделана. // OK means the check passed and the work is done.
OK ExitCode = 0 OK ExitCode = 0
// Failed — проверка нашла ошибки. // Failed means the check found errors.
Failed ExitCode = 1 Failed ExitCode = 1
// Usage — команда набрана неверно или не в том контексте. // Usage means the command was typed wrong or run in the wrong context.
Usage ExitCode = 2 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 { type Env struct {
Dir string Dir string
Out io.Writer Out io.Writer
@@ -34,7 +35,7 @@ type Env struct {
Colors bool Colors bool
} }
// Run разбирает аргументы и выполняет команду. // Run parses the arguments and executes the command.
func Run(env Env, args []string) ExitCode { func Run(env Env, args []string) ExitCode {
if len(args) == 0 { if len(args) == 0 {
usage(env.Out) usage(env.Out)
@@ -45,13 +46,13 @@ func Run(env Env, args []string) ExitCode {
case "suite": case "suite":
return runSuite(env, args[1:]) return runSuite(env, args[1:])
case "add", "pull", "list", "check": 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 return Usage
case "help", "-h", "--help": case "help", "-h", "--help":
usage(env.Out) usage(env.Out)
return OK return OK
default: default:
fmt.Fprintf(env.Err, "неизвестная команда %q\n\n", args[0]) fmt.Fprintf(env.Err, "unknown command %q\n\n", args[0])
usage(env.Err) usage(env.Err)
return Usage return Usage
} }
@@ -59,44 +60,43 @@ func Run(env Env, args []string) ExitCode {
func runSuite(env Env, args []string) ExitCode { func runSuite(env Env, args []string) ExitCode {
if len(args) == 0 { if len(args) == 0 {
fmt.Fprintln(env.Err, "convy suite: нужна подкоманда — check") fmt.Fprintln(env.Err, "convy suite: a subcommand is required — check")
return Usage return Usage
} }
switch args[0] { switch args[0] {
case "check": case "check":
return runSuiteCheck(env, args[1:]) return runSuiteCheck(env, args[1:])
case "new": case "new":
fmt.Fprintln(env.Err, "команда \"suite new\" ещё не реализована") fmt.Fprintln(env.Err, "the \"suite new\" command is not implemented yet")
return Usage return Usage
default: 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 return Usage
} }
} }
// usage печатает помощь, сгруппированную заголовками: в плоском списке уровни // usage prints the help grouped under headings: a flat list hides the levels.
// не видны.
func usage(w io.Writer) { func usage(w io.Writer) {
fmt.Fprint(w, `convy — управление конвенциями разработки. fmt.Fprint(w, `convy — tending development conventions.
В проекте: In a project:
convy add <тема> подписаться и собрать (не реализовано) convy add <topic> subscribe and assemble (not implemented)
convy pull пересобрать подписанное (не реализовано) convy pull reassemble what is subscribed (not implemented)
convy list что подключено и что доступно (не реализовано) convy list what is wired up and available (not implemented)
convy check проверить форму того, что здесь (не реализовано) convy check check the form of what is here (not implemented)
В наборе: In a suite:
convy suite check целостность набора: префиксы, темы, оси, ссылки, форма convy suite check suite integrity: prefixes, topics, axes, links, form
convy suite new новая тема (не реализовано) convy suite new a new topic (not implemented)
`) `)
} }
// Main — точка входа процесса. // Main is the entry point of the process.
func Main() int { func Main() int {
dir, err := os.Getwd() dir, err := os.Getwd()
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "не удалось определить текущую директорию:", err) fmt.Fprintln(os.Stderr, "cannot determine the current directory:", err)
return int(Usage) return int(Usage)
} }
env := Env{Dir: dir, Out: os.Stdout, Err: os.Stderr} env := Env{Dir: dir, Out: os.Stdout, Err: os.Stderr}
+16 -28
View File
@@ -16,8 +16,8 @@ import (
func runSuiteCheck(env Env, args []string) ExitCode { func runSuiteCheck(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy suite check", flag.ContinueOnError) fs := flag.NewFlagSet("convy suite check", flag.ContinueOnError)
fs.SetOutput(env.Err) fs.SetOutput(env.Err)
root := fs.String("root", "", "корень набора; по умолчанию ищется вверх от текущей директории") root := fs.String("root", "", "root of the suite; by default it is looked up upwards from the current directory")
quiet := fs.Bool("quiet", false, "печатать только находки") quiet := fs.Bool("quiet", false, "print findings only")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return Usage return Usage
} }
@@ -27,9 +27,9 @@ func runSuiteCheck(env Env, args []string) ExitCode {
found, err := manifest.Find(env.Dir) found, err := manifest.Find(env.Dir)
if err != nil { if err != nil {
if errors.Is(err, manifest.ErrNotFound) { 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 { 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 return Usage
} }
@@ -68,7 +68,7 @@ func printReport(w io.Writer, rep *check.Report, s *suite.Suite, quiet bool) {
if f.Line > 0 { if f.Line > 0 {
where = fmt.Sprintf(":%d", f.Line) 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 { if quiet {
@@ -77,36 +77,24 @@ func printReport(w io.Writer, rep *check.Report, s *suite.Suite, quiet bool) {
if len(findings) > 0 { if len(findings) > 0 {
fmt.Fprintln(w) fmt.Fprintln(w)
} }
fmt.Fprintf(w, "набор: %s, %s, версия языка %d (%s)\n", fmt.Fprintf(w, "suite: %s, %s, language version %d (%s)\n",
plural(len(s.Docs), "файл", "файла", "файлов"), plural(len(s.Docs), "file"),
plural(len(s.Manifest.LiveTopics()), "тема", "темы", "тем"), plural(len(s.Manifest.LiveTopics()), "topic"),
s.Manifest.Language.Version, s.Manifest.Language.Lang) s.Manifest.Language.Version, s.Manifest.Language.Lang)
switch { switch {
case rep.Errors() > 0: 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: case rep.Warnings() > 0:
fmt.Fprintf(w, "ошибок нет, предупреждений: %d\n", rep.Warnings()) fmt.Fprintf(w, "no errors, warnings: %d\n", rep.Warnings())
default: default:
fmt.Fprintln(w, "целостность набора в порядке") fmt.Fprintln(w, "suite integrity holds")
} }
} }
// plural согласует существительное с числом: 1 файл, 2 файла, 5 файлов. // plural agrees a noun with a count: 1 file, 4 files.
func plural(n int, one, few, many string) string { func plural(n int, noun string) string {
word := many if n == 1 {
switch { return fmt.Sprintf("%d %s", n, noun)
case n%100 >= 11 && n%100 <= 14:
case n%10 == 1:
word = one
case n%10 >= 2 && n%10 <= 4:
word = few
} }
return fmt.Sprintf("%d %s", n, word) return fmt.Sprintf("%d %ss", n, noun)
}
func label(s check.Severity) string {
if s == check.Warning {
return "предупреждение"
}
return "ошибка"
} }
+77 -73
View File
@@ -1,14 +1,15 @@
// Package doc разбирает файл, записанный языком конвенций: шапку, правила и // Package doc parses a file written in the conventions language: its front
// их блоки. // matter, its rules and their blocks.
// //
// Модель разбора взята прямо из языка. Правило — заголовок вида // The parsing model is taken straight from the language. A rule is a heading of
// `### <ПРЕФИКС>-<номер>. <название>`; область правила тянется от заголовка до // the form `### <PREFIX>-<number>. <title>`; the area of a rule runs from that
// следующего заголовка любого уровня. Внутри области текст принадлежит // heading to the next heading of any level. Inside the area text belongs to the
// последнему открытому блоку: метка блок открывает, и блок длится до следующей // last block opened: a mark opens a block, and the block lasts until the next
// метки или до конца области. Проза — то, что лежит вне областей правил. // mark or until the end of the area. Prose is what lies outside rule areas.
// //
// Границу считает разметка, а не суждение о том, где правило кончилось: ровно // The boundary is counted by the markup rather than by a judgement about where
// поэтому проверка «модальных слов вне правил нет» вообще реализуема. // a rule ended: that is exactly what makes the "no modal words outside rules"
// check implementable at all.
package doc package doc
import ( import (
@@ -21,62 +22,64 @@ import (
"git.vakhrushev.me/av/convy/internal/lang" "git.vakhrushev.me/av/convy/internal/lang"
) )
// Heading — заголовок любого уровня. // Heading is a heading of any level.
type Heading struct { type Heading struct {
Level int Level int
Text string Text string
Line int Line int
} }
// BlockKind различает блок нормы и блок под меткой. // BlockKind tells a norm block from a marked one.
type BlockKind int type BlockKind int
const ( const (
// Norm — блок нормы: открыт модальным словом. // Norm is a block of the norm, opened by a modal word.
Norm BlockKind = iota + 1 Norm BlockKind = iota + 1
// Marked — блок под меткой: ПОЧЕМУ, ПРИМЕРЫ, СНЯТО, МЕХАНИЗИРОВАНО. // Marked is a block under a mark: rationale, examples, retired,
// mechanized.
Marked Marked
) )
// Block — часть правила, открытая словом словаря в начале абзаца. // Block is a part of a rule opened by a vocabulary word at the start of a
// paragraph.
type Block struct { type Block struct {
Kind BlockKind Kind BlockKind
Word string Word string
Level lang.Level Level lang.Level
Mark lang.Mark Mark lang.Mark
// Start — строка, на которой стоит открывающая метка. // Start is the line the opening mark stands on.
Start int 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 End int
// Rest — текст абзаца после метки. // Rest is the text of the paragraph after the mark.
Rest string Rest string
} }
// Rule — правило: заголовок с идентификатором и его область. // Rule is a rule: a heading carrying an identifier, plus its area.
type Rule struct { type Rule struct {
Prefix string Prefix string
Num int Num int
Title string Title string
// Line — строка заголовка. // Line is the line of the heading.
Line int Line int
// HeadingLevel — уровень заголовка; каноническая форма — третий. // HeadingLevel is the level of the heading; the canonical form is three.
HeadingLevel int 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 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 Start, End int
Blocks []Block Blocks []Block
} }
// ID возвращает идентификатор правила. // ID returns the identifier of the rule.
func (r Rule) ID() string { func (r Rule) ID() string {
return fmt.Sprintf("%s-%d", r.Prefix, r.Num) 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) { func (r Rule) Block(m lang.Mark) (Block, bool) {
for _, b := range r.Blocks { for _, b := range r.Blocks {
if b.Kind == Marked && b.Mark == m { if b.Kind == Marked && b.Mark == m {
@@ -86,9 +89,9 @@ func (r Rule) Block(m lang.Mark) (Block, bool) {
return Block{}, false 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 { func (r Rule) Norms() []Block {
var out []Block var out []Block
for _, b := range r.Blocks { for _, b := range r.Blocks {
@@ -99,25 +102,25 @@ func (r Rule) Norms() []Block {
return out return out
} }
// Paragraph — абзац: строки между пустыми. // Paragraph is a paragraph: the lines between blank ones.
type Paragraph struct { type Paragraph struct {
Start, End int Start, End int
Lines []string Lines []string
} }
// Text склеивает абзац в одну строку. // Text joins the paragraph into a single string.
func (p Paragraph) Text() string { func (p Paragraph) Text() string {
return strings.Join(p.Lines, " ") return strings.Join(p.Lines, " ")
} }
// Document — разобранный файл. // Document is a parsed file.
type Document struct { type Document struct {
// Path — путь от корня набора, в форме со слэшами. // Path is the path from the root of the suite, in slash form.
Path string Path string
Front Front Front Front
lines []string lines []string
fence []bool fence []bool
// Body — первая строка тела, после шапки. // Body is the first line of the body, past the front matter.
Body int Body int
Headings []Heading Headings []Heading
Rules []Rule Rules []Rule
@@ -125,15 +128,15 @@ type Document struct {
var ( var (
headingRe = regexp.MustCompile(`^(#{1,6})\s+(.*)$`) 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+)(.*)$`) ruleHeadRe = regexp.MustCompile(`^([A-Z]{4})-(\d+)(.*)$`)
fenceRe = regexp.MustCompile("^\\s*(`{3,}|~{3,})") 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) { func Load(path, name string) (*Document, error) {
data, err := os.ReadFile(name) data, err := os.ReadFile(name)
if err != nil { if err != nil {
@@ -142,7 +145,7 @@ func Load(path, name string) (*Document, error) {
return Parse(path, string(data)) return Parse(path, string(data))
} }
// Parse разбирает содержимое файла. // Parse parses the contents of a file.
func Parse(path, content string) (*Document, error) { func Parse(path, content string) (*Document, error) {
d := &Document{Path: path} d := &Document{Path: path}
d.lines = strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") 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.Front = front
d.Body = body d.Body = body
if err != nil { if err != nil {
return d, fmt.Errorf("%s: шапка: %w", path, err) return d, fmt.Errorf("%s: front matter: %w", path, err)
} }
d.markFences() d.markFences()
@@ -160,10 +163,10 @@ func Parse(path, content string) (*Document, error) {
return d, nil return d, nil
} }
// markFences отмечает строки внутри огороженных блоков кода. Всё, что // markFences marks the lines inside fenced code blocks. Everything checked by
// проверяется разбором текста, эти строки пропускает: пример на SQL с // parsing text skips them: a SQL sample with an uppercase WHEN does not make
// заглавным WHEN не делает набор двуязычным, а `### XKEY-5` из примера в // the suite bilingual, and a `### XKEY-5` inside a documentation sample is not
// документации — не правило. // a rule.
func (d *Document) markFences() { func (d *Document) markFences() {
d.fence = make([]bool, len(d.lines)) d.fence = make([]bool, len(d.lines))
open := "" open := ""
@@ -217,7 +220,7 @@ func (d *Document) collectRules() {
Line: h.Line, Line: h.Line,
HeadingLevel: h.Level, HeadingLevel: h.Level,
Start: h.Line + 1, Start: h.Line + 1,
End: d.lineCount(), End: d.Len(),
} }
if i+1 < len(d.Headings) { if i+1 < len(d.Headings) {
rule.End = d.Headings[i+1].Line - 1 rule.End = d.Headings[i+1].Line - 1
@@ -228,19 +231,20 @@ func (d *Document) collectRules() {
case strings.HasPrefix(tail, ". "): case strings.HasPrefix(tail, ". "):
rule.Title = strings.TrimSpace(tail[2:]) rule.Title = strings.TrimSpace(tail[2:])
case tail == "": case tail == "":
rule.Malformed = "у заголовка правила нет названия" rule.Malformed = "the rule heading has no title"
case strings.HasPrefix(tail, "."): case strings.HasPrefix(tail, "."):
rule.Title = strings.TrimSpace(tail[1:]) rule.Title = strings.TrimSpace(tail[1:])
default: default:
rule.Malformed = "после идентификатора в заголовке нет точки" rule.Malformed = "no period after the identifier in the heading"
rule.Title = strings.TrimSpace(tail) rule.Title = strings.TrimSpace(tail)
} }
d.Rules = append(d.Rules, rule) 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) { func (d *Document) Blocks(v lang.Vocabulary) {
for i := range d.Rules { for i := range d.Rules {
r := &d.Rules[i] 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) { func boldLead(line string) (bold, rest string, ok bool) {
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
if !strings.HasPrefix(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 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 { func (d *Document) Paragraphs(from, to int) []Paragraph {
var out []Paragraph var out []Paragraph
var cur *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] line := d.lines[n-1]
if d.fence[n-1] || strings.TrimSpace(line) == "" { if d.fence[n-1] || strings.TrimSpace(line) == "" {
cur = nil cur = nil
@@ -306,15 +310,16 @@ func (d *Document) Paragraphs(from, to int) []Paragraph {
return out 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) { func (d *Document) Preamble() (from, to int) {
if len(d.Rules) == 0 { if len(d.Rules) == 0 {
return d.Body, d.lineCount() return d.Body, d.Len()
} }
return d.Body, d.Rules[0].Line - 1 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 { func (d *Document) InRule(n int) bool {
for _, r := range d.Rules { for _, r := range d.Rules {
if n >= r.Line && n <= r.End { if n >= r.Line && n <= r.End {
@@ -324,24 +329,25 @@ func (d *Document) InRule(n int) bool {
return false return false
} }
// Line возвращает строку с номером n. // Line returns line number n.
func (d *Document) Line(n int) string { func (d *Document) Line(n int) string {
if n < 1 || n > d.lineCount() { if n < 1 || n > d.Len() {
return "" return ""
} }
return d.lines[n-1] return d.lines[n-1]
} }
// Fenced отвечает, лежит ли строка внутри огороженного блока кода. // Fenced reports whether a line lies inside a fenced code block.
func (d *Document) Fenced(n int) bool { 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) { 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] { if d.fence[n-1] {
continue 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 { func StripInline(line string) string {
var b strings.Builder var b strings.Builder
inCode := false inCode := false
@@ -370,7 +376,5 @@ func StripInline(line string) string {
return b.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) Len() int { return len(d.lines) }
func (d *Document) lineCount() int { return len(d.lines) }
+18 -15
View File
@@ -5,11 +5,13 @@ import (
"strings" "strings"
) )
// Front — шапка файла. Ключей немного и все они плоские, поэтому разбор здесь // Front is the front matter of a file. There are few keys and all of them are
// свой: тащить YAML ради четырёх строк `ключ: значение` не за что. // flat, so the parsing is done here: pulling in YAML for four `key: value`
// lines earns nothing.
// //
// Ось слоя объявляется здесь ключами lang и stack, а не выводится из пути // The axis of a layer is declared here by the lang and stack keys rather than
// (META-38); отсутствие обоих означает базовый слой темы. // derived from the path (META-38); the absence of both means the base layer of
// the topic.
type Front struct { type Front struct {
Topic string Topic string
Prefix string Prefix string
@@ -17,25 +19,26 @@ type Front struct {
Stack string Stack string
Extends 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 At map[string]int
// Unknown — ключи, которых модель не знает. // Unknown lists keys the model does not know.
Unknown []string Unknown []string
// End — номер строки закрывающего разделителя. Тело файла начинается // End is the line of the closing delimiter. The body of the file starts
// со следующей. // on the next one.
End int End int
// Present — была ли шапка вообще. // Present says whether there was any front matter at all.
Present bool Present bool
} }
// Axis отвечает, объявлена ли у слоя ось. Слой без ключей оси — базовый. // Axis reports whether the layer declares an axis. A layer without one is the
// base layer.
func (f Front) Axis() bool { func (f Front) Axis() bool {
return f.Lang != "" || f.Stack != "" 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) { func parseFront(lines []string) (Front, int, error) {
front := Front{At: make(map[string]int)} front := Front{At: make(map[string]int)}
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" { 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, ":") key, value, ok := strings.Cut(line, ":")
if !ok { 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) key = strings.TrimSpace(key)
value = strings.TrimSpace(value) value = strings.TrimSpace(value)
@@ -76,5 +79,5 @@ func parseFront(lines []string) (Front, int, error) {
front.Unknown = append(front.Unknown, key) 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")
} }
+53 -48
View File
@@ -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.
// //
// Словарь — свойство версии языка и естественного языка набора, а не самого // A vocabulary belongs to a language version and to the suite's natural
// набора: версия 1 по-русски задаёт один и тот же список слов в любом // 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
// форма 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 package lang
import ( import (
@@ -17,9 +19,10 @@ import (
"unicode/utf8" "unicode/utf8"
) )
// Level — ступень шкалы обязательности. Ступеней пять в четырёх категориях // Level is a step on the scale of obligation. There are five steps in the four
// ISO/IEC Directives, Part 2; какими словами они названы — параметр // 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 type Level int
const ( const (
@@ -30,26 +33,26 @@ const (
Permission Permission
) )
// String даёт имя ступени для сообщений об ошибках — не слово словаря, а роль. // String names the step for diagnostics — the role, not the vocabulary word.
func (l Level) String() string { func (l Level) String() string {
switch l { switch l {
case Requirement: case Requirement:
return "требование" return "requirement"
case Prohibition: case Prohibition:
return "запрет" return "prohibition"
case Recommendation: case Recommendation:
return "рекомендация" return "recommendation"
case RecommendationAgainst: case RecommendationAgainst:
return "рекомендация против" return "recommendation against"
case Permission: 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 type Mark int
const ( const (
@@ -62,20 +65,20 @@ const (
func (m Mark) String() string { func (m Mark) String() string {
switch m { switch m {
case Rationale: case Rationale:
return "обоснование" return "rationale"
case Examples: case Examples:
return "примеры" return "examples"
case Mechanized: case Mechanized:
return "механизация" return "mechanized"
case Retired: 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 type Connective int
const ( const (
@@ -85,7 +88,7 @@ const (
Or Or
) )
// Vocabulary — словарь одной версии языка на одном естественном языке. // Vocabulary is the vocabulary of one language version in one natural language.
type Vocabulary struct { type Vocabulary struct {
Version int Version int
Code string Code string
@@ -94,19 +97,19 @@ type Vocabulary struct {
Scenario map[string]Connective 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) { func (v Vocabulary) Modal(word string) (Level, bool) {
l, ok := v.Modals[word] l, ok := v.Modals[word]
return l, ok 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) { func (v Vocabulary) Mark(word string) (Mark, bool) {
m, ok := v.Marks[word] m, ok := v.Marks[word]
return m, ok return m, ok
} }
// Word возвращает слово, которым в этом словаре записана ступень. // Word returns the word this vocabulary uses for a step.
func (v Vocabulary) Word(l Level) string { func (v Vocabulary) Word(l Level) string {
for w, got := range v.Modals { for w, got := range v.Modals {
if got == l { if got == l {
@@ -116,7 +119,7 @@ func (v Vocabulary) Word(l Level) string {
return "" return ""
} }
// MarkWord возвращает слово, которым в этом словаре записана метка. // MarkWord returns the word this vocabulary uses for a mark.
func (v Vocabulary) MarkWord(m Mark) string { func (v Vocabulary) MarkWord(m Mark) string {
for w, got := range v.Marks { for w, got := range v.Marks {
if got == m { if got == m {
@@ -126,9 +129,9 @@ func (v Vocabulary) MarkWord(m Mark) string {
return "" return ""
} }
// Lead возвращает слово словаря, которым начинается text, и его длину. // 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
// «ДОЛЖЕН», а метка с датой («СНЯТО 2026-07-26») — как метка без неё. // ("RETIRED 2026-07-26") must not be read as a different mark.
func (v Vocabulary) Lead(text string) (string, bool) { func (v Vocabulary) Lead(text string) (string, bool) {
best := "" best := ""
for _, w := range v.Words() { for _, w := range v.Words() {
@@ -143,8 +146,8 @@ func (v Vocabulary) Lead(text string) (string, bool) {
return best, best != "" 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 { func (v Vocabulary) Words() []string {
words := make([]string, 0, len(v.Modals)+len(v.Marks)) words := make([]string, 0, len(v.Modals)+len(v.Marks))
for w := range v.Modals { for w := range v.Modals {
@@ -157,8 +160,8 @@ func (v Vocabulary) Words() []string {
return words 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 { func hasWordPrefix(text, w string) bool {
if !strings.HasPrefix(text, w) { if !strings.HasPrefix(text, w) {
return false return false
@@ -171,8 +174,8 @@ func hasWordPrefix(text, w string) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r) 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{ var registry = map[int]map[string]Vocabulary{
1: { 1: {
"ru": { "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) { func Lookup(version int, code string) (Vocabulary, error) {
byCode, ok := registry[version] byCode, ok := registry[version]
if !ok { 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] v, ok := byCode[code]
if !ok { 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 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 { func Foreign(version int, code string) map[string]string {
own, err := Lookup(version, code) own, err := Lookup(version, code)
if err != nil { if err != nil {
+9 -8
View File
@@ -31,12 +31,12 @@ func TestLeadTakesLongestMatch(t *testing.T) {
got, ok := v.Lead(tc.text) got, ok := v.Lead(tc.text)
if tc.want == "" { if tc.want == "" {
if ok { 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 continue
} }
if !ok || got != tc.want { 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) { func TestForeignExcludesOwnWords(t *testing.T) {
foreign := lang.Foreign(1, "ru") foreign := lang.Foreign(1, "ru")
if len(foreign) == 0 { 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" { 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") v, err := lang.Lookup(1, "ru")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
for word := range foreign { for word := range foreign {
if _, own := v.Modal(word); own { 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 { 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) { func TestUnknownVersionAndCode(t *testing.T) {
if _, err := lang.Lookup(99, "ru"); err == nil { 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 { if _, err := lang.Lookup(1, "xx"); err == nil {
t.Error("неизвестный словарь принят без ошибки") t.Error("an unknown vocabulary was accepted without an error")
} }
} }
+44 -41
View File
@@ -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 package manifest
import ( import (
@@ -17,17 +17,19 @@ import (
"github.com/BurntSushi/toml" "github.com/BurntSushi/toml"
) )
// Name — имя манифеста набора. По тому, какой из двух манифестов лежит рядом, // Name is the name of a suite manifest. Which of the two manifests lies next
// определяется контекст: suite.toml — набор, .conventions.toml — проект. // to you tells you where you are: suite.toml means a suite, .conventions.toml
// means a project.
const Name = "suite.toml" 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" 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 { type Language struct {
Version int `toml:"version"` Version int `toml:"version"`
Lang string `toml:"lang"` Lang string `toml:"lang"`
@@ -35,39 +37,40 @@ type Language struct {
Reading string `toml:"reading"` 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 { type Section struct {
Live map[string]string `toml:"live"` Live map[string]string `toml:"live"`
Retired map[string]string `toml:"retired"` Retired map[string]string `toml:"retired"`
} }
// Manifest — разобранный suite.toml. // Manifest is a parsed suite.toml.
type Manifest struct { type Manifest struct {
Language Language `toml:"language"` Language Language `toml:"language"`
Topics Section `toml:"topics"` Topics Section `toml:"topics"`
Prefixes Section `toml:"prefixes"` Prefixes Section `toml:"prefixes"`
// Path — путь, по которому манифест прочитан. // Path is where the manifest was read from.
Path string `toml:"-"` 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:"-"` Undecoded []string `toml:"-"`
} }
// Load читает манифест набора из директории root. // Load reads the suite manifest from directory root.
func Load(root string) (*Manifest, error) { func Load(root string) (*Manifest, error) {
path := filepath.Join(root, Name) path := filepath.Join(root, Name)
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return nil, fmt.Errorf("чтение манифеста набора: %w", err) return nil, fmt.Errorf("reading the suite manifest: %w", err)
} }
var m Manifest var m Manifest
meta, err := toml.Decode(string(data), &m) meta, err := toml.Decode(string(data), &m)
if err != nil { if err != nil {
return nil, fmt.Errorf("разбор %s: %w", path, err) return nil, fmt.Errorf("parsing %s: %w", path, err)
} }
m.Path = path m.Path = path
for _, key := range meta.Undecoded() { for _, key := range meta.Undecoded() {
@@ -81,8 +84,8 @@ func Load(root string) (*Manifest, error) {
return &m, nil return &m, nil
} }
// Find поднимается от start вверх до корня, ища директорию с манифестом // Find walks up from start looking for a directory that holds a suite
// набора. Так `convy suite check` работает из любой поддиректории набора. // manifest, so that `convy suite check` works from any subdirectory of a suite.
func Find(start string) (string, error) { func Find(start string) (string, error) {
dir, err := filepath.Abs(start) dir, err := filepath.Abs(start)
if err != nil { if err != nil {
@@ -100,22 +103,22 @@ func Find(start string) (string, error) {
} }
} }
// ErrNotFound означает, что рядом и выше нет манифеста набора. // ErrNotFound means there is no suite manifest here or above.
var ErrNotFound = errors.New("манифест набора не найден") 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 { func (m *Manifest) LivePrefixes() []string {
return sortedKeys(m.Prefixes.Live) return sortedKeys(m.Prefixes.Live)
} }
// LiveTopics перечисляет живые темы в устойчивом порядке. // LiveTopics lists the live topics in a stable order.
func (m *Manifest) LiveTopics() []string { func (m *Manifest) LiveTopics() []string {
return sortedKeys(m.Topics.Live) 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) { func (m *Manifest) PrefixOf(path string) (string, bool) {
want := filepath.ToSlash(path) want := filepath.ToSlash(path)
for prefix, declared := range m.Prefixes.Live { for prefix, declared := range m.Prefixes.Live {
@@ -126,44 +129,44 @@ func (m *Manifest) PrefixOf(path string) (string, bool) {
return "", false return "", false
} }
// PathOf возвращает путь, объявленный за живым префиксом. // PathOf returns the path declared for a live prefix.
func (m *Manifest) PathOf(prefix string) (string, bool) { func (m *Manifest) PathOf(prefix string) (string, bool) {
path, ok := m.Prefixes.Live[prefix] path, ok := m.Prefixes.Live[prefix]
return path, ok return path, ok
} }
// TopicLive отвечает, объявлена ли тема среди живых. // TopicLive reports whether the topic is declared among the live ones.
func (m *Manifest) TopicLive(topic string) bool { func (m *Manifest) TopicLive(topic string) bool {
_, ok := m.Topics.Live[topic] _, ok := m.Topics.Live[topic]
return ok return ok
} }
// TopicRetired отвечает, значится ли тема среди выбывших. // TopicRetired reports whether the topic is listed among the retired ones.
func (m *Manifest) TopicRetired(topic string) bool { func (m *Manifest) TopicRetired(topic string) bool {
_, ok := m.Topics.Retired[topic] _, ok := m.Topics.Retired[topic]
return ok return ok
} }
// PrefixRetired отвечает, значится ли префикс среди выбывших. // PrefixRetired reports whether the prefix is listed among the retired ones.
func (m *Manifest) PrefixRetired(prefix string) bool { func (m *Manifest) PrefixRetired(prefix string) bool {
_, ok := m.Prefixes.Retired[prefix] _, ok := m.Prefixes.Retired[prefix]
return ok return ok
} }
// ValidPrefix проверяет форму префикса: четыре заглавные латинские буквы. // ValidPrefix checks the shape of a prefix: four uppercase Latin letters. The
// Буква X в начале зарезервирована за репозиториями-потребителями, и набор // letter X in first position is reserved for consuming repositories, and the
// её не занимает никогда. // suite never takes it.
func ValidPrefix(prefix string) error { func ValidPrefix(prefix string) error {
if len(prefix) != 4 { if len(prefix) != 4 {
return fmt.Errorf("префикс %q — не четыре буквы", prefix) return fmt.Errorf("prefix %q is not four letters", prefix)
} }
for _, r := range prefix { for _, r := range prefix {
if r < 'A' || r > 'Z' { 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") { 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 return nil
} }
+33 -29
View File
@@ -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 package suite
import ( import (
@@ -22,28 +23,28 @@ import (
"git.vakhrushev.me/av/convy/internal/manifest" "git.vakhrushev.me/av/convy/internal/manifest"
) )
// Suite — загруженный набор. // Suite is a loaded suite.
type Suite struct { type Suite struct {
Root string Root string
Manifest *manifest.Manifest Manifest *manifest.Manifest
Vocab lang.Vocabulary Vocab lang.Vocabulary
// Docs — документы набора в порядке живых префиксов. // Docs holds the documents of the suite in the order of live prefixes.
Docs []*doc.Document Docs []*doc.Document
// ByPrefix — документ по префиксу из манифеста. // ByPrefix maps a manifest prefix to its document.
ByPrefix map[string]*doc.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 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 Unregistered []string
// Broken — файлы, чью шапку не удалось разобрать. // Broken lists files whose front matter could not be parsed.
Broken []error Broken []error
} }
// Load читает набор из директории root. // Load reads a suite from directory root.
func Load(root string) (*Suite, error) { func Load(root string) (*Suite, error) {
m, err := manifest.Load(root) m, err := manifest.Load(root)
if err != nil { if err != nil {
@@ -85,9 +86,10 @@ func Load(root string) (*Suite, error) {
return s, nil return s, nil
} }
// findUnregistered обходит набор и ищет файлы, записанные языком конвенций, но // findUnregistered walks the suite looking for files written in the conventions
// не объявленные в манифесте. Признак — ключ prefix в шапке, а не // 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 { func (s *Suite) findUnregistered() error {
declared := make(map[string]bool) declared := make(map[string]bool)
for _, path := range s.Manifest.Prefixes.Live { for _, path := range s.Manifest.Prefixes.Live {
@@ -120,8 +122,9 @@ func (s *Suite) findUnregistered() error {
} }
d, err := doc.Load(rel, name) d, err := doc.Load(rel, name)
if err != nil { if err != nil {
// Шапки у файла нет или она сломана — для набора это не // The file carries no front matter, or it is broken — for the
// документ языка, а просто markdown рядом. // suite this is not a document of the language but plain
// markdown lying nearby.
return nil return nil
} }
if d.Front.Prefix != "" { if d.Front.Prefix != "" {
@@ -130,15 +133,15 @@ func (s *Suite) findUnregistered() error {
return nil return nil
}) })
if err != nil { if err != nil {
return fmt.Errorf("обход набора: %w", err) return fmt.Errorf("walking the suite: %w", err)
} }
sort.Strings(s.Unregistered) sort.Strings(s.Unregistered)
return nil 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 { func (s *Suite) Conventions() []*doc.Document {
var out []*doc.Document var out []*doc.Document
for _, d := range s.Docs { for _, d := range s.Docs {
@@ -149,7 +152,8 @@ func (s *Suite) Conventions() []*doc.Document {
return out 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 { func (s *Suite) Layers(topic string) []*doc.Document {
var out []*doc.Document var out []*doc.Document
for _, d := range s.Docs { for _, d := range s.Docs {
@@ -160,13 +164,13 @@ func (s *Suite) Layers(topic string) []*doc.Document {
return out return out
} }
// Prefix возвращает префикс, объявленный манифестом за документом. // Prefix returns the prefix the manifest declares for a document.
func (s *Suite) Prefix(d *doc.Document) string { func (s *Suite) Prefix(d *doc.Document) string {
prefix, _ := s.Manifest.PrefixOf(d.Path) prefix, _ := s.Manifest.PrefixOf(d.Path)
return prefix return prefix
} }
// Exists проверяет, есть ли в наборе файл по пути от корня. // Exists reports whether the suite holds a file at a path from its root.
func (s *Suite) Exists(rel string) bool { func (s *Suite) Exists(rel string) bool {
_, err := os.Stat(filepath.Join(s.Root, filepath.FromSlash(rel))) _, err := os.Stat(filepath.Join(s.Root, filepath.FromSlash(rel)))
return err == nil return err == nil
+2 -2
View File
@@ -1,5 +1,5 @@
// Команда convy — управление конвенциями разработки: проверка целостности // Command convy tends development conventions: it checks the integrity of a
// набора и сборка копий в проектах. // suite and assembles copies inside projects.
package main package main
import ( import (