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

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