комментарии и сообщения переведены на английский
- комментарии, тексты ошибок, вывод CLI и сообщения тестов теперь на английском - по-русски остались только литералы словаря ru и содержимое фикстур: это данные под проверкой, а не текст инструмента - согласование числительных в итоге упростилось до английского plural
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user