suite check: реализована проверка целостности набора
- разбор документа по языку конвенций: шапка, области правил, блоки под метками - проверки формы правила, распространения, ссылок и самого suite.toml - словарь языка живёт в бинаре реестром «версия × естественный язык», темы и префиксы берутся только из манифеста
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
module git.vakhrushev.me/av/convy
|
module git.vakhrushev.me/av/convy
|
||||||
|
|
||||||
go 1.26.5
|
go 1.26.5
|
||||||
|
|
||||||
|
require github.com/BurntSushi/toml v1.6.0
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
package check_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/convy/internal/check"
|
||||||
|
"git.vakhrushev.me/av/convy/internal/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// versionLine — строка о версии языка. Она перечисляет ключевые слова набора,
|
||||||
|
// поэтому единственная законно несёт модальные слова вне правил.
|
||||||
|
const versionLine = `Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
|
||||||
|
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
|
||||||
|
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.`
|
||||||
|
|
||||||
|
const baseManifest = `
|
||||||
|
[language]
|
||||||
|
version = 1
|
||||||
|
description = "LANGUAGE.md"
|
||||||
|
reading = "READING.md"
|
||||||
|
|
||||||
|
[topics.live]
|
||||||
|
time = "время: хранение, зоны, форматы"
|
||||||
|
|
||||||
|
[topics.retired]
|
||||||
|
|
||||||
|
[prefixes.live]
|
||||||
|
TIME = "conventions/time.md"
|
||||||
|
|
||||||
|
[prefixes.retired]
|
||||||
|
`
|
||||||
|
|
||||||
|
const baseTime = `---
|
||||||
|
topic: time
|
||||||
|
prefix: TIME
|
||||||
|
---
|
||||||
|
|
||||||
|
# Время
|
||||||
|
|
||||||
|
Как приложение записывает моменты.
|
||||||
|
|
||||||
|
` + versionLine + `
|
||||||
|
|
||||||
|
## Правила
|
||||||
|
|
||||||
|
### TIME-1. Момент записывается в UTC
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.
|
||||||
|
|
||||||
|
**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.
|
||||||
|
`
|
||||||
|
|
||||||
|
// files — содержимое набора: путь от корня к тексту файла. Пустая строка
|
||||||
|
// значит «файла нет»: так тест снимает файл, который есть в основе.
|
||||||
|
type files map[string]string
|
||||||
|
|
||||||
|
func base() files {
|
||||||
|
return files{
|
||||||
|
"suite.toml": baseManifest,
|
||||||
|
"LANGUAGE.md": "# Язык конвенций\n\nОписание языка.\n",
|
||||||
|
"READING.md": "# Как читать конвенцию\n\nКоротко.\n",
|
||||||
|
"conventions/time.md": baseTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run записывает набор во временную директорию и прогоняет проверки.
|
||||||
|
func run(t *testing.T, f files) []check.Finding {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
for name, content := range f {
|
||||||
|
if content == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := filepath.Join(root, filepath.FromSlash(name))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s, err := suite.Load(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("загрузка набора: %v", err)
|
||||||
|
}
|
||||||
|
return check.Suite(s).Findings()
|
||||||
|
}
|
||||||
|
|
||||||
|
func messages(findings []check.Finding) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, f := range findings {
|
||||||
|
b.WriteString(f.Path)
|
||||||
|
b.WriteString(": ")
|
||||||
|
b.WriteString(f.Msg)
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanSuite(t *testing.T) {
|
||||||
|
if got := run(t, base()); len(got) != 0 {
|
||||||
|
t.Fatalf("исправный набор дал находки:\n%s", messages(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rule собирает правило целиком, чтобы тесты не повторяли его форму.
|
||||||
|
func rule(id, title, norm, rationale string) string {
|
||||||
|
return "\n### " + id + ". " + title + "\n\n**ДОЛЖЕН.** " + norm + "\n\n**ПОЧЕМУ.** " + rationale + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChecks(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
setup func(files)
|
||||||
|
want string
|
||||||
|
}{{
|
||||||
|
name: "префикс в шапке расходится с манифестом",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/time.md"] = strings.Replace(baseTime, "prefix: TIME", "prefix: GTIM", 1)
|
||||||
|
},
|
||||||
|
want: "префикс в шапке — GTIM, а манифест объявляет за этим файлом TIME",
|
||||||
|
}, {
|
||||||
|
name: "заголовок правила несёт чужой префикс",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/time.md"] = strings.Replace(baseTime, "### TIME-1.", "### GTIM-1.", 1)
|
||||||
|
},
|
||||||
|
want: "использует префикс GTIM, а файлу принадлежит TIME",
|
||||||
|
}, {
|
||||||
|
name: "нумерация с дырой",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/time.md"] = baseTime + rule("TIME-3", "Третье", "Норма.", "Причина.")
|
||||||
|
},
|
||||||
|
want: "нумерация не сплошная",
|
||||||
|
}, {
|
||||||
|
name: "номер занят дважды",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/time.md"] = baseTime + rule("TIME-1", "Ещё раз первое", "Норма.", "Причина.")
|
||||||
|
},
|
||||||
|
want: "номер TIME-1 занят дважды",
|
||||||
|
}, {
|
||||||
|
name: "правило без обоснования",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/time.md"] = strings.Replace(baseTime,
|
||||||
|
"**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.", "", 1)
|
||||||
|
},
|
||||||
|
want: "нет блока ПОЧЕМУ: обоснование обязательно",
|
||||||
|
}, {
|
||||||
|
name: "правило без нормы и без заглушки",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/time.md"] = strings.Replace(baseTime,
|
||||||
|
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.", "Просто текст.", 1)
|
||||||
|
},
|
||||||
|
want: "нет ни блока нормы, ни заглушки СНЯТО",
|
||||||
|
}, {
|
||||||
|
name: "две нормы под одним номером",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/time.md"] = strings.Replace(baseTime,
|
||||||
|
"**ПОЧЕМУ.**", "**СЛЕДУЕТ.** Вторая норма.\n\n**ПОЧЕМУ.**", 1)
|
||||||
|
},
|
||||||
|
want: "две нормы (ДОЛЖЕН и СЛЕДУЕТ)",
|
||||||
|
}, {
|
||||||
|
name: "обоснование стоит раньше нормы",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/time.md"] = strings.Replace(baseTime,
|
||||||
|
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.\n\n**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.",
|
||||||
|
"**ПОЧЕМУ.** Причина вперёд.\n\n**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.", 1)
|
||||||
|
},
|
||||||
|
want: "обоснование стоит раньше нормы",
|
||||||
|
}, {
|
||||||
|
name: "примеры стоят раньше обоснования",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/time.md"] = strings.Replace(baseTime,
|
||||||
|
"**ПОЧЕМУ.**", "**ПРИМЕРЫ.** Иллюстрация.\n\n**ПОЧЕМУ.**", 1)
|
||||||
|
},
|
||||||
|
want: "блок ПРИМЕРЫ стоит раньше обоснования",
|
||||||
|
}, {
|
||||||
|
name: "заглушка снятого без даты",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/time.md"] = strings.Replace(baseTime,
|
||||||
|
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.\n\n**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.",
|
||||||
|
"**СНЯТО.** Правило убрано за ненадобностью.", 1)
|
||||||
|
},
|
||||||
|
want: "не несёт даты снятия",
|
||||||
|
}, {
|
||||||
|
name: "у снятого правила осталась норма",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/time.md"] = baseTime +
|
||||||
|
"\n### TIME-2. Снятое\n\n**СНЯТО 2026-07-26.** Причина снятия.\n\n**ДОЛЖЕН.** Остаток нормы.\n"
|
||||||
|
},
|
||||||
|
want: "остался блок нормы",
|
||||||
|
}, {
|
||||||
|
name: "нет строки о версии языка",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/time.md"] = strings.Replace(baseTime, versionLine, "Просто вводная проза.", 1)
|
||||||
|
},
|
||||||
|
want: "нет строки о версии языка",
|
||||||
|
}, {
|
||||||
|
name: "строка о версии называет чужую версию",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/time.md"] = strings.Replace(baseTime, "конвенций версии 1", "конвенций версии 2", 1)
|
||||||
|
},
|
||||||
|
want: "не называет версию 1",
|
||||||
|
}, {
|
||||||
|
name: "модальное слово вне области правила",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/time.md"] = strings.Replace(baseTime,
|
||||||
|
"Как приложение записывает моменты.", "Приложение ДОЛЖЕН писать моменты.", 1)
|
||||||
|
},
|
||||||
|
want: "стоит вне области правила",
|
||||||
|
}, {
|
||||||
|
name: "слово чужого словаря",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/time.md"] = strings.Replace(baseTime,
|
||||||
|
"**ПОЧЕМУ.** Без явного", "**ПОЧЕМУ.** Здесь MUST не к месту. Без явного", 1)
|
||||||
|
},
|
||||||
|
want: `слово MUST принадлежит словарю "en"`,
|
||||||
|
}, {
|
||||||
|
name: "ссылка на несуществующее правило",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/time.md"] = strings.Replace(baseTime,
|
||||||
|
"Без явного смещения", "Смотри TIME-9. Без явного смещения", 1)
|
||||||
|
},
|
||||||
|
want: "ссылка TIME-9 не разрешается",
|
||||||
|
}, {
|
||||||
|
name: "ссылка на неизвестный префикс",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/time.md"] = strings.Replace(baseTime,
|
||||||
|
"Без явного смещения", "Смотри ZZZZ-1. Без явного смещения", 1)
|
||||||
|
},
|
||||||
|
want: "префикс ZZZZ, которого в манифесте набора нет",
|
||||||
|
}, {
|
||||||
|
name: "тема не объявлена в манифесте",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/time.md"] = strings.Replace(baseTime, "topic: time", "topic: clocks", 1)
|
||||||
|
},
|
||||||
|
want: `тема "clocks" не объявлена`,
|
||||||
|
}, {
|
||||||
|
name: "тема значится среди выбывших",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["suite.toml"] = strings.Replace(baseManifest,
|
||||||
|
"[topics.retired]", `[topics.retired]`+"\ntime = \"снята 2026-07-01\"", 1)
|
||||||
|
},
|
||||||
|
want: "значится и среди живых, и среди выбывших",
|
||||||
|
}, {
|
||||||
|
name: "у живой темы нет слоёв",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["suite.toml"] = strings.Replace(baseManifest,
|
||||||
|
`time = "время: хранение, зоны, форматы"`,
|
||||||
|
`time = "время"`+"\nlogging = \"логирование\"", 1)
|
||||||
|
},
|
||||||
|
want: `тема "logging" объявлена живой, а слоёв у неё в наборе нет`,
|
||||||
|
}, {
|
||||||
|
name: "объявленного файла нет",
|
||||||
|
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", а файла в наборе нет`,
|
||||||
|
}, {
|
||||||
|
name: "файл не зарегистрирован в манифесте",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/logging.md"] = "---\ntopic: logging\nprefix: SLOG\n---\n\n# Логирование\n"
|
||||||
|
},
|
||||||
|
want: "в манифесте набора не объявлен",
|
||||||
|
}, {
|
||||||
|
name: "префикс начинается на 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: "зарезервирована за локальными правилами потребителей",
|
||||||
|
}, {
|
||||||
|
name: "на один файл объявлено два префикса",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["suite.toml"] = strings.Replace(baseManifest,
|
||||||
|
`TIME = "conventions/time.md"`,
|
||||||
|
`TIME = "conventions/time.md"`+"\nGTIM = \"conventions/time.md\"", 1)
|
||||||
|
},
|
||||||
|
want: "объявлено несколько префиксов",
|
||||||
|
}, {
|
||||||
|
name: "ключ манифеста неизвестен",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["suite.toml"] = baseManifest + "\n[extra]\nkey = 1\n"
|
||||||
|
},
|
||||||
|
want: "инструменту неизвестен",
|
||||||
|
}, {
|
||||||
|
name: "метка механизации в тексте конвенции",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/time.md"] = strings.Replace(baseTime,
|
||||||
|
"Без явного смещения", "Правило МЕХАНИЗИРОВАНО линтером. Без явного смещения", 1)
|
||||||
|
},
|
||||||
|
want: "её место — запись о механизации в локальной части копии",
|
||||||
|
}, {
|
||||||
|
name: "путь канона в тексте конвенции",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/time.md"] = strings.Replace(baseTime,
|
||||||
|
"Без явного смещения", "Смотри conventions/time.md. Без явного смещения", 1)
|
||||||
|
},
|
||||||
|
want: "стоит путь файла канона",
|
||||||
|
}}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
f := base()
|
||||||
|
tc.setup(f)
|
||||||
|
got := messages(run(t, f))
|
||||||
|
if !strings.Contains(got, tc.want) {
|
||||||
|
t.Fatalf("проверка не сработала\nждали: %s\nполучили:\n%s", tc.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
package check
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/convy/internal/doc"
|
||||||
|
"git.vakhrushev.me/av/convy/internal/lang"
|
||||||
|
"git.vakhrushev.me/av/convy/internal/manifest"
|
||||||
|
"git.vakhrushev.me/av/convy/internal/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// checkForm проверяет форму правила разбором текста. Применяется к любому
|
||||||
|
// файлу, который язык употребляет, — и к конвенциям, и к документу, которым
|
||||||
|
// набор ведёт себя сам.
|
||||||
|
func checkForm(s *suite.Suite, d *doc.Document, rep *Report) {
|
||||||
|
prefix := checkFilePrefix(s, d, rep)
|
||||||
|
checkHeadings(d, prefix, rep)
|
||||||
|
checkNumbering(d, prefix, rep)
|
||||||
|
checkRules(s, d, rep)
|
||||||
|
versionFrom, versionTo := checkVersionLine(s, d, rep)
|
||||||
|
checkModalsOutside(s, d, versionFrom, versionTo, rep)
|
||||||
|
checkForeignVocabulary(s, d, rep)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkFilePrefix сверяет префикс в шапке с манифестом и возвращает префикс,
|
||||||
|
// которым файлу положено пользоваться.
|
||||||
|
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)
|
||||||
|
return declared
|
||||||
|
}
|
||||||
|
if d.Front.Prefix == "" {
|
||||||
|
rep.Errorf(Form, d.Path, 1, "шапка не несёт ключа prefix")
|
||||||
|
return declared
|
||||||
|
}
|
||||||
|
at := d.Front.At["prefix"]
|
||||||
|
if d.Front.Prefix != declared {
|
||||||
|
rep.Errorf(Form, d.Path, at,
|
||||||
|
"префикс в шапке — %s, а манифест объявляет за этим файлом %s", 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)
|
||||||
|
}
|
||||||
|
for _, key := range d.Front.Unknown {
|
||||||
|
rep.Warnf(Form, d.Path, d.Front.At[key], "ключ шапки %q инструменту неизвестен", key)
|
||||||
|
}
|
||||||
|
return declared
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkHeadings проверяет форму заголовков правил: собственный префикс файла,
|
||||||
|
// третий уровень, точка после идентификатора, название.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if r.HeadingLevel != 3 {
|
||||||
|
rep.Errorf(Form, d.Path, r.Line,
|
||||||
|
"заголовок правила %s стоит на уровне %d, а правило — заголовок третьего уровня", r.ID(), r.HeadingLevel)
|
||||||
|
}
|
||||||
|
if r.Malformed != "" {
|
||||||
|
rep.Errorf(Form, d.Path, r.Line, "%s: %s", r.ID(), r.Malformed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkNumbering проверяет сплошную нумерацию: от единицы до наибольшего без
|
||||||
|
// пропусков и без повторов (META-31). Дыра неотличима от опечатки в номере и
|
||||||
|
// от правила, которое забыли дописать, — поэтому её нет никогда, а снятое
|
||||||
|
// правило остаётся заглушкой.
|
||||||
|
func checkNumbering(d *doc.Document, prefix string, rep *Report) {
|
||||||
|
seen := make(map[int][]int)
|
||||||
|
for _, r := range d.Rules {
|
||||||
|
if r.Prefix != prefix {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[r.Num] = append(seen[r.Num], r.Line)
|
||||||
|
}
|
||||||
|
if len(seen) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nums := make([]int, 0, len(seen))
|
||||||
|
highest := 0
|
||||||
|
for n := range seen {
|
||||||
|
nums = append(nums, n)
|
||||||
|
highest = max(highest, n)
|
||||||
|
}
|
||||||
|
sort.Ints(nums)
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var gaps []int
|
||||||
|
for n := 1; n <= highest; n++ {
|
||||||
|
if _, ok := seen[n]; !ok {
|
||||||
|
gaps = append(gaps, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(gaps) > 0 {
|
||||||
|
rep.Errorf(Form, d.Path, d.Rules[0].Line,
|
||||||
|
"нумерация не сплошная: наибольший номер %d, пропущены %s — снятое правило остаётся заглушкой, а не исчезает",
|
||||||
|
highest, joinInts(gaps))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var dateRe = regexp.MustCompile(`\d{4}-\d{2}-\d{2}`)
|
||||||
|
|
||||||
|
// checkRules проверяет состав правила: либо норма с обоснованием, либо
|
||||||
|
// заглушка снятого. Ни норма, ни обоснование не удаляются никогда
|
||||||
|
// (META-8, META-10).
|
||||||
|
func checkRules(s *suite.Suite, d *doc.Document, rep *Report) {
|
||||||
|
v := s.Vocab
|
||||||
|
for _, r := range d.Rules {
|
||||||
|
if retired, ok := r.Block(lang.Retired); ok {
|
||||||
|
checkRetired(d, r, retired, rep)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
norms := r.Norms()
|
||||||
|
switch len(norms) {
|
||||||
|
case 0:
|
||||||
|
rep.Errorf(Form, d.Path, r.Line,
|
||||||
|
"у правила %s нет ни блока нормы, ни заглушки %s", 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)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
rep.Errorf(Form, d.Path, norms[1].Start,
|
||||||
|
"у правила %s две нормы (%s и %s): норма — одна фраза, иначе нарушение одной её половины нечем адресовать",
|
||||||
|
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))
|
||||||
|
} else if len(norms) > 0 && rationale.Start < norms[0].Start {
|
||||||
|
rep.Errorf(Form, d.Path, rationale.Start,
|
||||||
|
"у правила %s обоснование стоит раньше нормы: порядок блоков — норма, %s, %s",
|
||||||
|
r.ID(), v.MarkWord(lang.Rationale), v.MarkWord(lang.Examples))
|
||||||
|
}
|
||||||
|
|
||||||
|
if examples, ok := r.Block(lang.Examples); ok {
|
||||||
|
switch {
|
||||||
|
case len(r.Blocks) > 0 && r.Blocks[0].Start == examples.Start:
|
||||||
|
rep.Errorf(Form, d.Path, examples.Start,
|
||||||
|
"у правила %s блок %s открывает правило: порядок блоков — норма, %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 стоит раньше обоснования: сначала требование, потом причина, потом иллюстрация",
|
||||||
|
r.ID(), v.MarkWord(lang.Examples))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkRetired проверяет заглушку снятого правила: дата и причина.
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
if !dateRe.MatchString(d.Line(retired.Start)) {
|
||||||
|
rep.Errorf(Form, d.Path, retired.Start,
|
||||||
|
"заглушка правила %s не несёт даты снятия", r.ID())
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(retired.Rest) == "" {
|
||||||
|
rep.Errorf(Form, d.Path, retired.Start,
|
||||||
|
"заглушка правила %s не несёт причины снятия", r.ID())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkVersionLine ищет во вводной прозе строку о версии языка и возвращает
|
||||||
|
// границы абзаца, который её несёт.
|
||||||
|
//
|
||||||
|
// Строка перечисляет ключевые слова набора и сама несёт правило заглавных —
|
||||||
|
// поэтому она единственное место вне правил, где модальные слова законны.
|
||||||
|
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,
|
||||||
|
"во вводной прозе нет строки о версии языка: она перечисляет ключевые слова набора и без неё конвенция в чужом репозитории теряет ключ к собственному тексту")
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
version := strconv.Itoa(s.Manifest.Language.Version)
|
||||||
|
if !containsNumber(p.Text(), version) {
|
||||||
|
rep.Errorf(Form, d.Path, p.Start,
|
||||||
|
"строка о версии языка не называет версию %s, объявленную манифестом набора", version)
|
||||||
|
}
|
||||||
|
return p.Start, p.End
|
||||||
|
}
|
||||||
|
|
||||||
|
// versionParagraph ищет во вводной прозе абзац, несущий строку о версии языка:
|
||||||
|
// тот, где перечислены все ключевые слова набора. Ничего не сообщает — о его
|
||||||
|
// отсутствии говорит checkVersionLine, и говорить дважды незачем.
|
||||||
|
func versionParagraph(s *suite.Suite, d *doc.Document) (doc.Paragraph, bool) {
|
||||||
|
from, to := d.Preamble()
|
||||||
|
words := s.Vocab.Words()
|
||||||
|
for _, p := range d.Paragraphs(from, to) {
|
||||||
|
if containsAll(p.Text(), words) {
|
||||||
|
return p, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return doc.Paragraph{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkModalsOutside ищет заглавные модальные слова вне областей правил.
|
||||||
|
// Область — от заголовка правила до следующего заголовка; всё остальное проза,
|
||||||
|
// а проза нормой не является никогда.
|
||||||
|
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 {
|
||||||
|
if d.InRule(n) || n >= versionFrom && n <= versionTo {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, w := range words {
|
||||||
|
if !containsWord(text, w) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rep.Errorf(Form, d.Path, n,
|
||||||
|
"модальное слово %s стоит вне области правила: заглавное написание нормативно, и в прозе его быть не может", w)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkForeignVocabulary ищет слова чужого словаря той же версии языка.
|
||||||
|
// Словарь один на набор: две формы записи одного требования удваивают каждую
|
||||||
|
// проверку.
|
||||||
|
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 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
words := make([]string, 0, len(foreign))
|
||||||
|
for w := range foreign {
|
||||||
|
words = append(words, w)
|
||||||
|
}
|
||||||
|
sort.Strings(words)
|
||||||
|
|
||||||
|
d.Prose(func(n int, text string) bool {
|
||||||
|
for _, w := range words {
|
||||||
|
if containsWord(text, w) {
|
||||||
|
rep.Errorf(Form, d.Path, n,
|
||||||
|
"слово %s принадлежит словарю %q, а набор объявляет словарь %q",
|
||||||
|
w, foreign[w], s.Manifest.Language.Lang)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func modalWords(v lang.Vocabulary) []string {
|
||||||
|
out := make([]string, 0, len(v.Modals))
|
||||||
|
for w := range v.Modals {
|
||||||
|
out = append(out, w)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsAll(text string, words []string) bool {
|
||||||
|
for _, w := range words {
|
||||||
|
if !strings.Contains(text, w) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsWord ищет слово как целое: «ДОЛЖЕНСТВОВАНИЕ» словом ДОЛЖЕН не
|
||||||
|
// является, а «ДОЛЖЕН.» — является.
|
||||||
|
func containsWord(text, word string) bool {
|
||||||
|
for i := 0; ; {
|
||||||
|
j := strings.Index(text[i:], word)
|
||||||
|
if j < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
start := i + j
|
||||||
|
end := start + len(word)
|
||||||
|
if !letterBefore(text, start) && !letterAt(text, end) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
i = start + len(word)
|
||||||
|
if i >= len(text) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsNumber(text, number string) bool {
|
||||||
|
for i := 0; ; {
|
||||||
|
j := strings.Index(text[i:], number)
|
||||||
|
if j < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
start := i + j
|
||||||
|
end := start + len(number)
|
||||||
|
if !digitBefore(text, start) && !digitAt(text, end) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
i = end
|
||||||
|
if i >= len(text) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ruleID(prefix string, num int) string {
|
||||||
|
return prefix + "-" + strconv.Itoa(num)
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinInts(nums []int) string {
|
||||||
|
parts := make([]string, len(nums))
|
||||||
|
for i, n := range nums {
|
||||||
|
parts[i] = strconv.Itoa(n)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
package check_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Набор с двумя слоями одной темы: базовый арх-слой и языковой поверх него.
|
||||||
|
// На нём проверяется всё, что про оси, extends и границу самодостаточности
|
||||||
|
// нормы, — на одном слое эти проверки выразить нечем.
|
||||||
|
|
||||||
|
const layeredManifest = `
|
||||||
|
[language]
|
||||||
|
version = 1
|
||||||
|
description = "LANGUAGE.md"
|
||||||
|
reading = "READING.md"
|
||||||
|
|
||||||
|
[topics.live]
|
||||||
|
time = "время"
|
||||||
|
logging = "логирование"
|
||||||
|
|
||||||
|
[topics.retired]
|
||||||
|
|
||||||
|
[prefixes.live]
|
||||||
|
TIME = "conventions/arch/time.md"
|
||||||
|
GTIM = "conventions/lang/go/time.md"
|
||||||
|
SLOG = "conventions/arch/logging.md"
|
||||||
|
|
||||||
|
[prefixes.retired]
|
||||||
|
`
|
||||||
|
|
||||||
|
const archTime = `---
|
||||||
|
topic: time
|
||||||
|
prefix: TIME
|
||||||
|
---
|
||||||
|
|
||||||
|
# Время
|
||||||
|
|
||||||
|
Как приложение записывает моменты.
|
||||||
|
|
||||||
|
` + versionLine + `
|
||||||
|
|
||||||
|
## Правила
|
||||||
|
|
||||||
|
### TIME-1. Момент записывается в UTC
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.
|
||||||
|
|
||||||
|
**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.
|
||||||
|
`
|
||||||
|
|
||||||
|
const goTime = `---
|
||||||
|
topic: time
|
||||||
|
prefix: GTIM
|
||||||
|
lang: go
|
||||||
|
extends: arch/time.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# Время: реализация на Go
|
||||||
|
|
||||||
|
Как требования базового слоя выполняются в Go-коде.
|
||||||
|
|
||||||
|
` + versionLine + `
|
||||||
|
|
||||||
|
## Правила
|
||||||
|
|
||||||
|
### GTIM-1. «Сейчас» берётся у слоя хранилища
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Текущее время приходит из store.Now().
|
||||||
|
|
||||||
|
**ПОЧЕМУ.** Единая точка даёт гарантированный UTC.
|
||||||
|
`
|
||||||
|
|
||||||
|
const archLogging = `---
|
||||||
|
topic: logging
|
||||||
|
prefix: SLOG
|
||||||
|
---
|
||||||
|
|
||||||
|
# Логирование
|
||||||
|
|
||||||
|
Как приложение пишет записи.
|
||||||
|
|
||||||
|
` + versionLine + `
|
||||||
|
|
||||||
|
## Правила
|
||||||
|
|
||||||
|
### SLOG-1. Уровень выбирается по адресату
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Уровень отвечает на вопрос «кому сообщение».
|
||||||
|
|
||||||
|
**ПОЧЕМУ.** Адресат — единственный воспроизводимый признак.
|
||||||
|
`
|
||||||
|
|
||||||
|
func layered() files {
|
||||||
|
return files{
|
||||||
|
"suite.toml": layeredManifest,
|
||||||
|
"LANGUAGE.md": "# Язык конвенций\n\nОписание языка.\n",
|
||||||
|
"READING.md": "# Как читать конвенцию\n\nКоротко.\n",
|
||||||
|
"conventions/arch/time.md": archTime,
|
||||||
|
"conventions/lang/go/time.md": goTime,
|
||||||
|
"conventions/arch/logging.md": archLogging,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayeredSuiteIsClean(t *testing.T) {
|
||||||
|
if got := run(t, layered()); len(got) != 0 {
|
||||||
|
t.Fatalf("исправный многослойный набор дал находки:\n%s", messages(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayeredChecks(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
setup func(files)
|
||||||
|
want string
|
||||||
|
}{{
|
||||||
|
name: "ось в шапке расходится с путём",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/lang/go/time.md"] = strings.Replace(goTime, "lang: go", "lang: python", 1)
|
||||||
|
},
|
||||||
|
want: `путь кладёт файл на ось lang=go, а шапка объявляет lang="python"`,
|
||||||
|
}, {
|
||||||
|
name: "extends ведёт в чужую тему",
|
||||||
|
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"`,
|
||||||
|
}, {
|
||||||
|
name: "extends ведёт в несуществующий файл",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/lang/go/time.md"] = strings.Replace(goTime,
|
||||||
|
"extends: arch/time.md", "extends: arch/clocks.md", 1)
|
||||||
|
},
|
||||||
|
want: `а такого файла в наборе нет`,
|
||||||
|
}, {
|
||||||
|
name: "у темы два слоя без ключей оси",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["suite.toml"] = strings.Replace(layeredManifest,
|
||||||
|
`GTIM = "conventions/lang/go/time.md"`,
|
||||||
|
`GTIM = "conventions/second/time.md"`, 1)
|
||||||
|
f["conventions/lang/go/time.md"] = ""
|
||||||
|
f["conventions/second/time.md"] = strings.Replace(
|
||||||
|
strings.Replace(goTime, "lang: go\n", "", 1),
|
||||||
|
"extends: arch/time.md\n", "", 1)
|
||||||
|
},
|
||||||
|
want: "больше одного слоя без ключей оси",
|
||||||
|
}, {
|
||||||
|
name: "норма ссылается на префикс чужой темы",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/lang/go/time.md"] = strings.Replace(goTime,
|
||||||
|
"**ДОЛЖЕН.** Текущее время приходит из store.Now().",
|
||||||
|
"**ДОЛЖЕН.** Текущее время приходит из store.Now() и пишется по SLOG-1.", 1)
|
||||||
|
},
|
||||||
|
want: `ссылается на SLOG-1 из чужой темы "logging"`,
|
||||||
|
}, {
|
||||||
|
name: "норма ссылается на неба́зовый слой своей темы",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/arch/time.md"] = strings.Replace(archTime,
|
||||||
|
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.",
|
||||||
|
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z, как требует GTIM-1.", 1)
|
||||||
|
},
|
||||||
|
want: "слой своей темы, но не базовый",
|
||||||
|
}}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
f := layered()
|
||||||
|
tc.setup(f)
|
||||||
|
got := messages(run(t, f))
|
||||||
|
if !strings.Contains(got, tc.want) {
|
||||||
|
t.Fatalf("проверка не сработала\nждали: %s\nполучили:\n%s", tc.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNoFalsePositives собирает случаи, в которых проверка обязана промолчать.
|
||||||
|
// Ложное срабатывание здесь дороже пропуска: проверку, которая краснеет на
|
||||||
|
// исправном файле, выключают целиком.
|
||||||
|
func TestNoFalsePositives(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
setup func(files)
|
||||||
|
}{{
|
||||||
|
name: "идентификатор в бэктиках — образец записи, а не ссылка",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/arch/time.md"] = strings.Replace(archTime,
|
||||||
|
"Без явного смещения",
|
||||||
|
"На правило ссылаются идентификатором (`TIME-99`). Без явного смещения", 1)
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
name: "модальное слово внутри огороженного блока кода",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/arch/time.md"] = archTime +
|
||||||
|
"\n## Связано\n\n```\nДОЛЖЕН это не норма, а строка примера\n```\n"
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
name: "заглавное SQL-слово в примере кода",
|
||||||
|
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: "норма языкового слоя ссылается на базовый слой своей темы",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/lang/go/time.md"] = strings.Replace(goTime,
|
||||||
|
"**ДОЛЖЕН.** Текущее время приходит из store.Now().",
|
||||||
|
"**ДОЛЖЕН.** Текущее время приходит из store.Now() в форме TIME-1.", 1)
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
name: "упоминание ступени в обосновании — не вторая норма",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/arch/time.md"] = strings.Replace(archTime,
|
||||||
|
"**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.",
|
||||||
|
"**ПОЧЕМУ.** Для ступени СЛЕДУЕТ это было бы честно, но здесь ломается сортировка.", 1)
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
name: "заглушка снятого правила с датой и причиной",
|
||||||
|
setup: func(f files) {
|
||||||
|
f["conventions/arch/time.md"] = archTime +
|
||||||
|
"\n### TIME-2. Ширина строки фиксируется\n\n**СНЯТО 2026-07-26.** Правило переехало в GTIM-1.\n"
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
f := layered()
|
||||||
|
tc.setup(f)
|
||||||
|
if got := run(t, f); len(got) != 0 {
|
||||||
|
t.Fatalf("проверка сработала там, где не должна:\n%s", messages(got))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package check
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/convy/internal/doc"
|
||||||
|
"git.vakhrushev.me/av/convy/internal/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// refRe ловит идентификатор правила: четыре заглавные латинские буквы, дефис,
|
||||||
|
// номер и необязательный номер строки таблицы.
|
||||||
|
var refRe = regexp.MustCompile(`\b([A-Z]{4})-(\d+)(?:\.(\d+))?`)
|
||||||
|
|
||||||
|
// Ref — ссылка на правило, найденная в тексте.
|
||||||
|
type Ref struct {
|
||||||
|
Prefix string
|
||||||
|
Num int
|
||||||
|
Sub int
|
||||||
|
Line int
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
|
||||||
|
// refsIn собирает ссылки в диапазоне строк документа. Инлайн-код вырезан: в
|
||||||
|
// бэктиках идентификатор стоит образцом записи, а не ссылкой на утверждение, —
|
||||||
|
// иначе строка «на конкретное правило ссылаются идентификатором (`SLOG-27`)»
|
||||||
|
// требовала бы, чтобы правило SLOG-27 существовало.
|
||||||
|
func refsIn(d *doc.Document, from, to int) []Ref {
|
||||||
|
var out []Ref
|
||||||
|
for n := from; n <= to; n++ {
|
||||||
|
if d.Fenced(n) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, refsInLine(n, doc.StripInline(d.Line(n)))...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func refsInLine(n int, text string) []Ref {
|
||||||
|
var out []Ref
|
||||||
|
for _, m := range refRe.FindAllStringSubmatch(text, -1) {
|
||||||
|
num, err := strconv.Atoi(m[2])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ref := Ref{Prefix: m[1], Num: num, Line: n, Text: m[0]}
|
||||||
|
if m[3] != "" {
|
||||||
|
ref.Sub, _ = strconv.Atoi(m[3])
|
||||||
|
}
|
||||||
|
out = append(out, ref)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkLinks проверяет, что каждая ссылка разрешается. Неразрешённый
|
||||||
|
// идентификатор всегда ошибка: с заглушками на месте снятых правил третьего
|
||||||
|
// исхода нет — ссылка ведёт либо к правилу, либо к объяснению, почему его
|
||||||
|
// сняли (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") {
|
||||||
|
// Префикс потребителя: локальные правила чужого репозитория
|
||||||
|
// набору не видны и разрешению не подлежат.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s.Manifest.PrefixRetired(ref.Prefix) {
|
||||||
|
rep.Errorf(Links, d.Path, ref.Line,
|
||||||
|
"ссылка %s ведёт на выбывший префикс %s", ref.Text, ref.Prefix)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
target, ok := s.ByPrefix[ref.Prefix]
|
||||||
|
if !ok {
|
||||||
|
if _, declared := s.Manifest.PathOf(ref.Prefix); declared {
|
||||||
|
// Файл объявлен, но не прочитан — о нём уже сказано
|
||||||
|
// проверкой манифеста, второй раз не повторяем.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rep.Errorf(Links, d.Path, ref.Line,
|
||||||
|
"ссылка %s ведёт на префикс %s, которого в манифесте набора нет", 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)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ref.Sub > 0 && !mentions(target, rule, ref.Text) {
|
||||||
|
rep.Errorf(Links, d.Path, ref.Line,
|
||||||
|
"ссылка %s не разрешается: в области %s такой строки нет", ref.Text, rule.ID())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mentions отвечает, встречается ли текст ссылки в области правила. Так
|
||||||
|
// проверяется номер строки таблицы: сама строка его и несёт.
|
||||||
|
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) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func ruleByNum(d *doc.Document, num int) (doc.Rule, bool) {
|
||||||
|
for _, r := range d.Rules {
|
||||||
|
if r.Num == num {
|
||||||
|
return r, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return doc.Rule{}, false
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// Package check прогоняет проверки целостности набора.
|
||||||
|
//
|
||||||
|
// Деление на семейства взято из языка и сохранено в коде: форма правила
|
||||||
|
// проверяется в любом файле, который язык употребляет; распространение — только
|
||||||
|
// в файлах конвенций, потому что эти проверки о том, что документ уезжает к
|
||||||
|
// потребителю. Третья часть списка — взаимоисключительность строк таблицы,
|
||||||
|
// покрытие области действия, самодостаточность нормы — сюда не входит: она не
|
||||||
|
// даётся разбором текста и остаётся работой читателя.
|
||||||
|
package check
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Severity различает ошибку и предупреждение. Ошибка — нарушение, названное
|
||||||
|
// правилом набора; предупреждение — то, что стоит посмотреть глазами.
|
||||||
|
type Severity int
|
||||||
|
|
||||||
|
const (
|
||||||
|
Error Severity = iota + 1
|
||||||
|
Warning
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s Severity) String() string {
|
||||||
|
if s == Warning {
|
||||||
|
return "предупреждение"
|
||||||
|
}
|
||||||
|
return "ошибка"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Family — семейство проверок, из которого пришла находка.
|
||||||
|
type Family string
|
||||||
|
|
||||||
|
const (
|
||||||
|
Manifest Family = "манифест"
|
||||||
|
Form Family = "форма"
|
||||||
|
Spread Family = "распространение"
|
||||||
|
Links Family = "ссылки"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Finding — одна находка.
|
||||||
|
type Finding struct {
|
||||||
|
Severity Severity
|
||||||
|
Family Family
|
||||||
|
// Path — путь файла от корня набора; пусто, если находка о наборе целиком.
|
||||||
|
Path string
|
||||||
|
// Line — строка файла; ноль, если находка не привязана к строке.
|
||||||
|
Line int
|
||||||
|
Msg string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report накапливает находки одного прогона.
|
||||||
|
type Report struct {
|
||||||
|
findings []Finding
|
||||||
|
}
|
||||||
|
|
||||||
|
// Errorf записывает ошибку.
|
||||||
|
func (r *Report) Errorf(f Family, path string, line int, format string, args ...any) {
|
||||||
|
r.add(Error, f, path, line, format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warnf записывает предупреждение.
|
||||||
|
func (r *Report) Warnf(f Family, path string, line int, format string, args ...any) {
|
||||||
|
r.add(Warning, f, path, line, format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Report) add(s Severity, f Family, path string, line int, format string, args ...any) {
|
||||||
|
r.findings = append(r.findings, Finding{
|
||||||
|
Severity: s,
|
||||||
|
Family: f,
|
||||||
|
Path: path,
|
||||||
|
Line: line,
|
||||||
|
Msg: fmt.Sprintf(format, args...),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Findings отдаёт находки в порядке файла и строки. Находки о наборе целиком
|
||||||
|
// идут первыми: пока манифест не сходится, остальное читать рано.
|
||||||
|
func (r *Report) Findings() []Finding {
|
||||||
|
out := make([]Finding, len(r.findings))
|
||||||
|
copy(out, r.findings)
|
||||||
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
if out[i].Path != out[j].Path {
|
||||||
|
return out[i].Path < out[j].Path
|
||||||
|
}
|
||||||
|
return out[i].Line < out[j].Line
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Errors считает находки уровня ошибки.
|
||||||
|
func (r *Report) Errors() int {
|
||||||
|
n := 0
|
||||||
|
for _, f := range r.findings {
|
||||||
|
if f.Severity == Error {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warnings считает предупреждения.
|
||||||
|
func (r *Report) Warnings() int {
|
||||||
|
return len(r.findings) - r.Errors()
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package check
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/convy/internal/doc"
|
||||||
|
"git.vakhrushev.me/av/convy/internal/lang"
|
||||||
|
"git.vakhrushev.me/av/convy/internal/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// checkSpread проверяет то, что относится к отъезду документа к потребителю.
|
||||||
|
// Применяется только к файлам конвенций: документ, которым набор ведёт себя
|
||||||
|
// сам, не уезжает никуда, и путь канона в нём законен.
|
||||||
|
func checkSpread(s *suite.Suite, d *doc.Document, rep *Report) {
|
||||||
|
checkTopic(s, d, rep)
|
||||||
|
checkAxis(d, rep)
|
||||||
|
checkExtends(s, d, rep)
|
||||||
|
checkMechanized(s, d, rep)
|
||||||
|
checkCanonPaths(s, d, rep)
|
||||||
|
checkForeignTopicPrefix(s, d, rep)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkTopic сверяет тему из шапки с манифестом (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)
|
||||||
|
case !s.Manifest.TopicLive(topic):
|
||||||
|
rep.Errorf(Spread, d.Path, at,
|
||||||
|
"тема %q не объявлена в манифесте набора", topic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkAxis сверяет объявленную ось с путём файла. Ось объявляется в шапке, а
|
||||||
|
// не выводится из пути (META-38); но если директории осей используются,
|
||||||
|
// расхождение означает переезд файла без правки шапки.
|
||||||
|
func checkAxis(d *doc.Document, rep *Report) {
|
||||||
|
parts := strings.Split(path.Dir(d.Path), "/")
|
||||||
|
for i := 0; i+1 < len(parts); i++ {
|
||||||
|
var declared, key string
|
||||||
|
switch parts[i] {
|
||||||
|
case "lang":
|
||||||
|
declared, key = d.Front.Lang, "lang"
|
||||||
|
case "stack":
|
||||||
|
declared, key = d.Front.Stack, "stack"
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if declared == parts[i+1] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
at := d.Front.At[key]
|
||||||
|
if at == 0 {
|
||||||
|
at = d.Front.At["prefix"]
|
||||||
|
}
|
||||||
|
rep.Errorf(Spread, d.Path, at,
|
||||||
|
"путь кладёт файл на ось %s=%s, а шапка объявляет %s=%q", key, parts[i+1], key, declared)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkExtends проверяет, что объявленная база существует и принадлежит той же
|
||||||
|
// теме. Ключ документирует связь слоёв для человека — документация, которая
|
||||||
|
// врёт, хуже отсутствующей.
|
||||||
|
func checkExtends(s *suite.Suite, d *doc.Document, rep *Report) {
|
||||||
|
if d.Front.Extends == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
at := d.Front.At["extends"]
|
||||||
|
target := resolveExtends(s, d.Front.Extends)
|
||||||
|
if target == nil {
|
||||||
|
rep.Errorf(Spread, d.Path, at,
|
||||||
|
"extends указывает на %q, а такого файла в наборе нет", d.Front.Extends)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if target.Front.Topic != d.Front.Topic {
|
||||||
|
rep.Errorf(Spread, d.Path, at,
|
||||||
|
"extends указывает на %q с темой %q, а файл несёт тему %q: слои одной темы объявляют одно имя",
|
||||||
|
d.Front.Extends, target.Front.Topic, d.Front.Topic)
|
||||||
|
}
|
||||||
|
if target.Front.Axis() {
|
||||||
|
rep.Errorf(Spread, d.Path, at,
|
||||||
|
"extends указывает на %q, а это не базовый слой: у него объявлена ось", d.Front.Extends)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveExtends ищет документ по пути, записанному в extends. Путь даётся от
|
||||||
|
// директории конвенций, поэтому пробуем и его, и путь от корня набора.
|
||||||
|
func resolveExtends(s *suite.Suite, ref string) *doc.Document {
|
||||||
|
ref = path.Clean(strings.TrimPrefix(ref, "./"))
|
||||||
|
for _, d := range s.Docs {
|
||||||
|
if d.Path == ref || strings.HasSuffix(d.Path, "/"+ref) {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkMechanized ищет метку механизации в тексте конвенции. Механизирована
|
||||||
|
// норма или нет — свойство репозитория, а не набора, поэтому место отметки —
|
||||||
|
// локальная часть копии (META-7).
|
||||||
|
func checkMechanized(s *suite.Suite, d *doc.Document, rep *Report) {
|
||||||
|
word := s.Vocab.MarkWord(lang.Mechanized)
|
||||||
|
if word == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
version, hasVersion := versionParagraph(s, d)
|
||||||
|
d.Prose(func(n int, text string) bool {
|
||||||
|
if hasVersion && n >= version.Start && n <= version.End {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if containsWord(text, word) {
|
||||||
|
rep.Errorf(Spread, d.Path, n,
|
||||||
|
"метка %s стоит в тексте конвенции: её место — запись о механизации в локальной части копии", word)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// mdPathRe ловит то, что выглядит путём к файлу набора.
|
||||||
|
var mdPathRe = regexp.MustCompile(`[\w./-]+\.md`)
|
||||||
|
|
||||||
|
// checkCanonPaths ищет путь файла канона в тексте конвенции (META-21). В
|
||||||
|
// репозитории потребителя конвенция лежит собранной, слои одной темы — секции
|
||||||
|
// одного файла, и путь `lang/go/logging.md` там не существует: ссылка на него
|
||||||
|
// умирает при сборке, причём молча — текст остаётся связным.
|
||||||
|
//
|
||||||
|
// Инлайн-код здесь не вырезается: путь в бэктиках — тоже путь.
|
||||||
|
func checkCanonPaths(s *suite.Suite, d *doc.Document, rep *Report) {
|
||||||
|
for n := d.Body; n <= d.Len(); n++ {
|
||||||
|
if d.Fenced(n) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, candidate := range mdPathRe.FindAllString(d.Line(n), -1) {
|
||||||
|
target := resolveExtends(s, candidate)
|
||||||
|
if target == nil && !s.Exists(candidate) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rep.Errorf(Spread, d.Path, n,
|
||||||
|
"в тексте стоит путь файла канона %q: ссылаются именем темы или идентификатором правила", candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkForeignTopicPrefix ищет префикс чужой темы в блоке нормы (META-20).
|
||||||
|
// Норму правила можно исполнить, имея один этот файл: репозиторий подписывается
|
||||||
|
// на произвольное подмножество конвенций, и графа зависимостей у него нет.
|
||||||
|
// Префикс базового слоя своей темы там допустим (META-24) — собранный файл
|
||||||
|
// начинается с него независимо от выбранных языка и стека.
|
||||||
|
func checkForeignTopicPrefix(s *suite.Suite, d *doc.Document, rep *Report) {
|
||||||
|
own := s.Prefix(d)
|
||||||
|
for _, r := range d.Rules {
|
||||||
|
for _, norm := range r.Norms() {
|
||||||
|
for _, ref := range refsIn(d, norm.Start, norm.End) {
|
||||||
|
if ref.Prefix == own || strings.HasPrefix(ref.Prefix, "X") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
target, ok := s.ByPrefix[ref.Prefix]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if target.Front.Topic != d.Front.Topic {
|
||||||
|
rep.Errorf(Spread, d.Path, ref.Line,
|
||||||
|
"норма %s ссылается на %s из чужой темы %q: наружу смотрит только обоснование",
|
||||||
|
r.ID(), ref.Text, target.Front.Topic)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if target.Front.Axis() {
|
||||||
|
rep.Errorf(Spread, d.Path, ref.Line,
|
||||||
|
"норма %s ссылается на %s — слой своей темы, но не базовый: в копию он попадает по манифесту, и гарантии, что он рядом, нет",
|
||||||
|
r.ID(), ref.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package check
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/convy/internal/manifest"
|
||||||
|
"git.vakhrushev.me/av/convy/internal/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Suite прогоняет все проверки набора и возвращает отчёт.
|
||||||
|
func Suite(s *suite.Suite) *Report {
|
||||||
|
rep := &Report{}
|
||||||
|
checkManifest(s, rep)
|
||||||
|
checkBaseLayers(s, rep)
|
||||||
|
|
||||||
|
for _, d := range s.Docs {
|
||||||
|
checkForm(s, d, rep)
|
||||||
|
checkLinks(s, d, rep)
|
||||||
|
if d.Front.Topic != "" {
|
||||||
|
checkSpread(s, d, rep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rep
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkManifest проверяет сам манифест: форму префиксов, непересечение живого
|
||||||
|
// с выбывшим, наличие объявленных файлов и отсутствие незарегистрированных.
|
||||||
|
func checkManifest(s *suite.Suite, rep *Report) {
|
||||||
|
m := s.Manifest
|
||||||
|
|
||||||
|
for _, key := range m.Undecoded {
|
||||||
|
rep.Warnf(Manifest, manifest.Name, 0, "ключ %s инструменту неизвестен", 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
byPath := make(map[string][]string)
|
||||||
|
for _, prefix := range m.LivePrefixes() {
|
||||||
|
if err := manifest.ValidPrefix(prefix); err != nil {
|
||||||
|
rep.Errorf(Manifest, manifest.Name, 0, "%s", err)
|
||||||
|
}
|
||||||
|
if m.PrefixRetired(prefix) {
|
||||||
|
rep.Errorf(Manifest, manifest.Name, 0,
|
||||||
|
"префикс %s значится и среди живых, и среди выбывших: выбывший не выдаётся повторно", prefix)
|
||||||
|
}
|
||||||
|
path := m.Prefixes.Live[prefix]
|
||||||
|
byPath[path] = append(byPath[path], prefix)
|
||||||
|
}
|
||||||
|
for _, path := range sortedKeys(byPath) {
|
||||||
|
if prefixes := byPath[path]; len(prefixes) > 1 {
|
||||||
|
sort.Strings(prefixes)
|
||||||
|
rep.Errorf(Manifest, manifest.Name, 0,
|
||||||
|
"на файл %q объявлено несколько префиксов (%v): префикс принадлежит файлу", path, prefixes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for prefix := range m.Prefixes.Retired {
|
||||||
|
if err := manifest.ValidPrefix(prefix); err != nil {
|
||||||
|
rep.Errorf(Manifest, manifest.Name, 0, "среди выбывших: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, prefix := range sortedKeys(s.Missing) {
|
||||||
|
rep.Errorf(Manifest, manifest.Name, 0,
|
||||||
|
"префикс %s объявлен за файлом %q, а файла в наборе нет", prefix, s.Missing[prefix])
|
||||||
|
}
|
||||||
|
for _, path := range s.Unregistered {
|
||||||
|
rep.Errorf(Manifest, path, 1,
|
||||||
|
"файл записан языком конвенций, но в манифесте набора не объявлен: для набора его нет")
|
||||||
|
}
|
||||||
|
for _, err := range s.Broken {
|
||||||
|
rep.Errorf(Manifest, manifest.Name, 0, "%s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, topic := range m.LiveTopics() {
|
||||||
|
if m.TopicRetired(topic) {
|
||||||
|
rep.Errorf(Manifest, manifest.Name, 0,
|
||||||
|
"тема %q значится и среди живых, и среди выбывших", topic)
|
||||||
|
}
|
||||||
|
if len(s.Layers(topic)) == 0 {
|
||||||
|
rep.Errorf(Manifest, manifest.Name, 0,
|
||||||
|
"тема %q объявлена живой, а слоёв у неё в наборе нет: тема живёт, пока есть хотя бы один слой", topic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkBaseLayers проверяет, что у темы не больше одного слоя без ключей оси.
|
||||||
|
// Базовый слой единственный: он попадает в копию всегда, и второй такой
|
||||||
|
// означал бы два базовых текста в одном собранном файле.
|
||||||
|
func checkBaseLayers(s *suite.Suite, rep *Report) {
|
||||||
|
for _, topic := range s.Manifest.LiveTopics() {
|
||||||
|
var base []string
|
||||||
|
for _, d := range s.Layers(topic) {
|
||||||
|
if !d.Front.Axis() {
|
||||||
|
base = append(base, d.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(base) > 1 {
|
||||||
|
sort.Strings(base)
|
||||||
|
for _, path := range base[1:] {
|
||||||
|
rep.Errorf(Spread, path, 1,
|
||||||
|
"у темы %q больше одного слоя без ключей оси: базовый слой единственный, остальные — %v",
|
||||||
|
topic, base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedKeys[V any](m map[string]V) []string {
|
||||||
|
keys := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package check
|
||||||
|
|
||||||
|
import (
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// letterAt отвечает, стоит ли по смещению i буква или цифра. Кириллица в UTF-8
|
||||||
|
// занимает два байта, поэтому решать по одному байту нельзя.
|
||||||
|
func letterAt(text string, i int) bool {
|
||||||
|
if i >= len(text) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
r, _ := utf8.DecodeRuneInString(text[i:])
|
||||||
|
return unicode.IsLetter(r) || unicode.IsDigit(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// letterBefore отвечает, стоит ли перед смещением i буква или цифра.
|
||||||
|
func letterBefore(text string, i int) bool {
|
||||||
|
if i <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
r, _ := utf8.DecodeLastRuneInString(text[:i])
|
||||||
|
return unicode.IsLetter(r) || unicode.IsDigit(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func digitAt(text string, i int) bool {
|
||||||
|
if i >= len(text) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
r, _ := utf8.DecodeRuneInString(text[i:])
|
||||||
|
return unicode.IsDigit(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func digitBefore(text string, i int) bool {
|
||||||
|
if i <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
r, _ := utf8.DecodeLastRuneInString(text[:i])
|
||||||
|
return unicode.IsDigit(r)
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// Package cli раскладывает команды инструмента.
|
||||||
|
//
|
||||||
|
// Глубина команды отражает частоту и адресата: проектные команды выполняются в
|
||||||
|
// каждом репозитории и часто, ведение набора — в одном репозитории и редко.
|
||||||
|
// Поэтому `check` стоит наверху, а под `suite` уходит то, что в проекте не
|
||||||
|
// имеет смысла. Синонимов нет: `convy suite pull` рядом с `convy pull` не
|
||||||
|
// заводится.
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExitCode — код возврата процесса.
|
||||||
|
type ExitCode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// OK — проверка прошла, работа сделана.
|
||||||
|
OK ExitCode = 0
|
||||||
|
// Failed — проверка нашла ошибки.
|
||||||
|
Failed ExitCode = 1
|
||||||
|
// Usage — команда набрана неверно или не в том контексте.
|
||||||
|
Usage ExitCode = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// Env — окружение запуска. Вынесено, чтобы команды тестировались без процесса.
|
||||||
|
type Env struct {
|
||||||
|
Dir string
|
||||||
|
Out io.Writer
|
||||||
|
Err io.Writer
|
||||||
|
NoTTY bool
|
||||||
|
Colors bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run разбирает аргументы и выполняет команду.
|
||||||
|
func Run(env Env, args []string) ExitCode {
|
||||||
|
if len(args) == 0 {
|
||||||
|
usage(env.Out)
|
||||||
|
return Usage
|
||||||
|
}
|
||||||
|
|
||||||
|
switch args[0] {
|
||||||
|
case "suite":
|
||||||
|
return runSuite(env, args[1:])
|
||||||
|
case "add", "pull", "list", "check":
|
||||||
|
fmt.Fprintf(env.Err, "команда %q ещё не реализована\n", args[0])
|
||||||
|
return Usage
|
||||||
|
case "help", "-h", "--help":
|
||||||
|
usage(env.Out)
|
||||||
|
return OK
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(env.Err, "неизвестная команда %q\n\n", args[0])
|
||||||
|
usage(env.Err)
|
||||||
|
return Usage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSuite(env Env, args []string) ExitCode {
|
||||||
|
if len(args) == 0 {
|
||||||
|
fmt.Fprintln(env.Err, "convy suite: нужна подкоманда — check")
|
||||||
|
return Usage
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "check":
|
||||||
|
return runSuiteCheck(env, args[1:])
|
||||||
|
case "new":
|
||||||
|
fmt.Fprintln(env.Err, "команда \"suite new\" ещё не реализована")
|
||||||
|
return Usage
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(env.Err, "неизвестная подкоманда %q для convy suite\n", args[0])
|
||||||
|
return Usage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// usage печатает помощь, сгруппированную заголовками: в плоском списке уровни
|
||||||
|
// не видны.
|
||||||
|
func usage(w io.Writer) {
|
||||||
|
fmt.Fprint(w, `convy — управление конвенциями разработки.
|
||||||
|
|
||||||
|
В проекте:
|
||||||
|
convy add <тема> подписаться и собрать (не реализовано)
|
||||||
|
convy pull пересобрать подписанное (не реализовано)
|
||||||
|
convy list что подключено и что доступно (не реализовано)
|
||||||
|
convy check проверить форму того, что здесь (не реализовано)
|
||||||
|
|
||||||
|
В наборе:
|
||||||
|
convy suite check целостность набора: префиксы, темы, оси, ссылки, форма
|
||||||
|
convy suite new новая тема (не реализовано)
|
||||||
|
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main — точка входа процесса.
|
||||||
|
func Main() int {
|
||||||
|
dir, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "не удалось определить текущую директорию:", err)
|
||||||
|
return int(Usage)
|
||||||
|
}
|
||||||
|
env := Env{Dir: dir, Out: os.Stdout, Err: os.Stderr}
|
||||||
|
return int(Run(env, os.Args[1:]))
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/convy/internal/check"
|
||||||
|
"git.vakhrushev.me/av/convy/internal/manifest"
|
||||||
|
"git.vakhrushev.me/av/convy/internal/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
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, "печатать только находки")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return Usage
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := *root
|
||||||
|
if dir == "" {
|
||||||
|
found, err := manifest.Find(env.Dir)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, manifest.ErrNotFound) {
|
||||||
|
fmt.Fprintf(env.Err, "здесь не набор конвенций: рядом и выше нет %s\n", manifest.Name)
|
||||||
|
if _, err := os.Stat(filepath.Join(env.Dir, ".conventions.toml")); err == nil {
|
||||||
|
fmt.Fprintln(env.Err, "это проект — проверка того, что здесь, называется \"convy check\"")
|
||||||
|
}
|
||||||
|
return Usage
|
||||||
|
}
|
||||||
|
fmt.Fprintln(env.Err, err)
|
||||||
|
return Usage
|
||||||
|
}
|
||||||
|
dir = found
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := suite.Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(env.Err, err)
|
||||||
|
return Usage
|
||||||
|
}
|
||||||
|
|
||||||
|
rep := check.Suite(s)
|
||||||
|
printReport(env.Out, rep, s, *quiet)
|
||||||
|
if rep.Errors() > 0 {
|
||||||
|
return Failed
|
||||||
|
}
|
||||||
|
return OK
|
||||||
|
}
|
||||||
|
|
||||||
|
func printReport(w io.Writer, rep *check.Report, s *suite.Suite, quiet bool) {
|
||||||
|
findings := rep.Findings()
|
||||||
|
current := ""
|
||||||
|
for _, f := range findings {
|
||||||
|
if f.Path != current {
|
||||||
|
if current != "" {
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s\n", f.Path)
|
||||||
|
current = f.Path
|
||||||
|
}
|
||||||
|
where := ""
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
if quiet {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(findings) > 0 {
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "набор: %d файлов, %d тем, версия языка %d (%s)\n",
|
||||||
|
len(s.Docs), len(s.Manifest.LiveTopics()), s.Manifest.Language.Version, s.Manifest.Language.Lang)
|
||||||
|
switch {
|
||||||
|
case rep.Errors() > 0:
|
||||||
|
fmt.Fprintf(w, "ошибок: %d, предупреждений: %d\n", rep.Errors(), rep.Warnings())
|
||||||
|
case rep.Warnings() > 0:
|
||||||
|
fmt.Fprintf(w, "ошибок нет, предупреждений: %d\n", rep.Warnings())
|
||||||
|
default:
|
||||||
|
fmt.Fprintln(w, "целостность набора в порядке")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func label(s check.Severity) string {
|
||||||
|
if s == check.Warning {
|
||||||
|
return "предупреждение"
|
||||||
|
}
|
||||||
|
return "ошибка"
|
||||||
|
}
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
// Package doc разбирает файл, записанный языком конвенций: шапку, правила и
|
||||||
|
// их блоки.
|
||||||
|
//
|
||||||
|
// Модель разбора взята прямо из языка. Правило — заголовок вида
|
||||||
|
// `### <ПРЕФИКС>-<номер>. <название>`; область правила тянется от заголовка до
|
||||||
|
// следующего заголовка любого уровня. Внутри области текст принадлежит
|
||||||
|
// последнему открытому блоку: метка блок открывает, и блок длится до следующей
|
||||||
|
// метки или до конца области. Проза — то, что лежит вне областей правил.
|
||||||
|
//
|
||||||
|
// Границу считает разметка, а не суждение о том, где правило кончилось: ровно
|
||||||
|
// поэтому проверка «модальных слов вне правил нет» вообще реализуема.
|
||||||
|
package doc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/convy/internal/lang"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Heading — заголовок любого уровня.
|
||||||
|
type Heading struct {
|
||||||
|
Level int
|
||||||
|
Text string
|
||||||
|
Line int
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockKind различает блок нормы и блок под меткой.
|
||||||
|
type BlockKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Norm — блок нормы: открыт модальным словом.
|
||||||
|
Norm BlockKind = iota + 1
|
||||||
|
// Marked — блок под меткой: ПОЧЕМУ, ПРИМЕРЫ, СНЯТО, МЕХАНИЗИРОВАНО.
|
||||||
|
Marked
|
||||||
|
)
|
||||||
|
|
||||||
|
// Block — часть правила, открытая словом словаря в начале абзаца.
|
||||||
|
type Block struct {
|
||||||
|
Kind BlockKind
|
||||||
|
Word string
|
||||||
|
Level lang.Level
|
||||||
|
Mark lang.Mark
|
||||||
|
// Start — строка, на которой стоит открывающая метка.
|
||||||
|
Start int
|
||||||
|
// End — последняя строка блока: блок длится до следующей метки или до
|
||||||
|
// конца области правила.
|
||||||
|
End int
|
||||||
|
// Rest — текст абзаца после метки.
|
||||||
|
Rest string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule — правило: заголовок с идентификатором и его область.
|
||||||
|
type Rule struct {
|
||||||
|
Prefix string
|
||||||
|
Num int
|
||||||
|
Title string
|
||||||
|
// Line — строка заголовка.
|
||||||
|
Line int
|
||||||
|
// HeadingLevel — уровень заголовка; каноническая форма — третий.
|
||||||
|
HeadingLevel int
|
||||||
|
// Malformed — заголовок опознан как правило, но записан не по форме
|
||||||
|
// `### <ПРЕФИКС>-<номер>. <название>`.
|
||||||
|
Malformed string
|
||||||
|
// Start, End — область правила: от строки после заголовка до строки
|
||||||
|
// перед следующим заголовком включительно.
|
||||||
|
Start, End int
|
||||||
|
Blocks []Block
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID возвращает идентификатор правила.
|
||||||
|
func (r Rule) ID() string {
|
||||||
|
return fmt.Sprintf("%s-%d", r.Prefix, r.Num)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block ищет первый блок под указанной меткой.
|
||||||
|
func (r Rule) Block(m lang.Mark) (Block, bool) {
|
||||||
|
for _, b := range r.Blocks {
|
||||||
|
if b.Kind == Marked && b.Mark == m {
|
||||||
|
return b, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Block{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Norms перечисляет блоки нормы. Их должно быть ровно ноль (у снятого
|
||||||
|
// правила) или один: норма — одна фраза, две нормы под одним номером нечем
|
||||||
|
// адресовать по отдельности.
|
||||||
|
func (r Rule) Norms() []Block {
|
||||||
|
var out []Block
|
||||||
|
for _, b := range r.Blocks {
|
||||||
|
if b.Kind == Norm {
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paragraph — абзац: строки между пустыми.
|
||||||
|
type Paragraph struct {
|
||||||
|
Start, End int
|
||||||
|
Lines []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text склеивает абзац в одну строку.
|
||||||
|
func (p Paragraph) Text() string {
|
||||||
|
return strings.Join(p.Lines, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Document — разобранный файл.
|
||||||
|
type Document struct {
|
||||||
|
// Path — путь от корня набора, в форме со слэшами.
|
||||||
|
Path string
|
||||||
|
Front Front
|
||||||
|
lines []string
|
||||||
|
fence []bool
|
||||||
|
// Body — первая строка тела, после шапки.
|
||||||
|
Body int
|
||||||
|
Headings []Heading
|
||||||
|
Rules []Rule
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
headingRe = regexp.MustCompile(`^(#{1,6})\s+(.*)$`)
|
||||||
|
// ruleHeadRe ловит заголовок, начинающийся с идентификатора правила, —
|
||||||
|
// в том числе записанный не по форме: иначе опечатка в заголовке
|
||||||
|
// превратила бы правило в прозу и молча исчезла из нумерации.
|
||||||
|
ruleHeadRe = regexp.MustCompile(`^([A-Z]{4})-(\d+)(.*)$`)
|
||||||
|
fenceRe = regexp.MustCompile("^\\s*(`{3,}|~{3,})")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Load читает и разбирает файл. path — путь от корня набора, name — путь в
|
||||||
|
// файловой системе.
|
||||||
|
func Load(path, name string) (*Document, error) {
|
||||||
|
data, err := os.ReadFile(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Parse(path, string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse разбирает содержимое файла.
|
||||||
|
func Parse(path, content string) (*Document, error) {
|
||||||
|
d := &Document{Path: path}
|
||||||
|
d.lines = strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
|
||||||
|
|
||||||
|
front, body, err := parseFront(d.lines)
|
||||||
|
d.Front = front
|
||||||
|
d.Body = body
|
||||||
|
if err != nil {
|
||||||
|
return d, fmt.Errorf("%s: шапка: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
d.markFences()
|
||||||
|
d.collectHeadings()
|
||||||
|
d.collectRules()
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// markFences отмечает строки внутри огороженных блоков кода. Всё, что
|
||||||
|
// проверяется разбором текста, эти строки пропускает: пример на SQL с
|
||||||
|
// заглавным WHEN не делает набор двуязычным, а `### XKEY-5` из примера в
|
||||||
|
// документации — не правило.
|
||||||
|
func (d *Document) markFences() {
|
||||||
|
d.fence = make([]bool, len(d.lines))
|
||||||
|
open := ""
|
||||||
|
for i, line := range d.lines {
|
||||||
|
m := fenceRe.FindStringSubmatch(line)
|
||||||
|
if open == "" {
|
||||||
|
if m != nil {
|
||||||
|
open = m[1]
|
||||||
|
d.fence[i] = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d.fence[i] = true
|
||||||
|
if m != nil && len(m[1]) >= len(open) && m[1][0] == open[0] {
|
||||||
|
open = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Document) collectHeadings() {
|
||||||
|
for i, line := range d.lines {
|
||||||
|
num := i + 1
|
||||||
|
if num < d.Body || d.fence[i] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := headingRe.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d.Headings = append(d.Headings, Heading{
|
||||||
|
Level: len(m[1]),
|
||||||
|
Text: strings.TrimSpace(m[2]),
|
||||||
|
Line: num,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Document) collectRules() {
|
||||||
|
for i, h := range d.Headings {
|
||||||
|
m := ruleHeadRe.FindStringSubmatch(h.Text)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
num, err := strconv.Atoi(m[2])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rule := Rule{
|
||||||
|
Prefix: m[1],
|
||||||
|
Num: num,
|
||||||
|
Line: h.Line,
|
||||||
|
HeadingLevel: h.Level,
|
||||||
|
Start: h.Line + 1,
|
||||||
|
End: d.lineCount(),
|
||||||
|
}
|
||||||
|
if i+1 < len(d.Headings) {
|
||||||
|
rule.End = d.Headings[i+1].Line - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
tail := m[3]
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(tail, ". "):
|
||||||
|
rule.Title = strings.TrimSpace(tail[2:])
|
||||||
|
case tail == "":
|
||||||
|
rule.Malformed = "у заголовка правила нет названия"
|
||||||
|
case strings.HasPrefix(tail, "."):
|
||||||
|
rule.Title = strings.TrimSpace(tail[1:])
|
||||||
|
default:
|
||||||
|
rule.Malformed = "после идентификатора в заголовке нет точки"
|
||||||
|
rule.Title = strings.TrimSpace(tail)
|
||||||
|
}
|
||||||
|
d.Rules = append(d.Rules, rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blocks размечает области правил по словарю набора. Разметка отложена до
|
||||||
|
// загрузки манифеста: до неё неизвестно, каким словарём записан набор.
|
||||||
|
func (d *Document) Blocks(v lang.Vocabulary) {
|
||||||
|
for i := range d.Rules {
|
||||||
|
r := &d.Rules[i]
|
||||||
|
r.Blocks = nil
|
||||||
|
for _, p := range d.Paragraphs(r.Start, r.End) {
|
||||||
|
word, rest, ok := boldLead(p.Lines[0])
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
w, ok := v.Lead(word)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b := Block{Word: w, Start: p.Start, End: r.End, Rest: strings.TrimSpace(rest)}
|
||||||
|
if level, ok := v.Modal(w); ok {
|
||||||
|
b.Kind, b.Level = Norm, level
|
||||||
|
} else {
|
||||||
|
mark, _ := v.Mark(w)
|
||||||
|
b.Kind, b.Mark = Marked, mark
|
||||||
|
}
|
||||||
|
if n := len(r.Blocks); n > 0 {
|
||||||
|
r.Blocks[n-1].End = p.Start - 1
|
||||||
|
}
|
||||||
|
r.Blocks = append(r.Blocks, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// boldLead выделяет содержимое первого полужирного участка, если абзац с него
|
||||||
|
// начинается. Метка стоит первой в своём абзаце, полужирным и с точкой —
|
||||||
|
// именно этим она отличается от упоминания ступени в середине фразы.
|
||||||
|
func boldLead(line string) (bold, rest string, ok bool) {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if !strings.HasPrefix(line, "**") {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
end := strings.Index(line[2:], "**")
|
||||||
|
if end < 0 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return line[2 : 2+end], line[2+end+2:], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paragraphs режет диапазон строк на абзацы. Границы включительные, нумерация
|
||||||
|
// с единицы. Строки внутри огороженных блоков в абзацы не попадают: код —
|
||||||
|
// иллюстрация, а не текст правила.
|
||||||
|
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++ {
|
||||||
|
line := d.lines[n-1]
|
||||||
|
if d.fence[n-1] || strings.TrimSpace(line) == "" {
|
||||||
|
cur = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cur == nil {
|
||||||
|
out = append(out, Paragraph{Start: n, End: n})
|
||||||
|
cur = &out[len(out)-1]
|
||||||
|
}
|
||||||
|
cur.Lines = append(cur.Lines, line)
|
||||||
|
cur.End = n
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preamble возвращает границы вводной прозы: от тела до первого правила.
|
||||||
|
func (d *Document) Preamble() (from, to int) {
|
||||||
|
if len(d.Rules) == 0 {
|
||||||
|
return d.Body, d.lineCount()
|
||||||
|
}
|
||||||
|
return d.Body, d.Rules[0].Line - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// InRule отвечает, лежит ли строка внутри области какого-нибудь правила.
|
||||||
|
func (d *Document) InRule(n int) bool {
|
||||||
|
for _, r := range d.Rules {
|
||||||
|
if n >= r.Line && n <= r.End {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line возвращает строку с номером n.
|
||||||
|
func (d *Document) Line(n int) string {
|
||||||
|
if n < 1 || n > d.lineCount() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return d.lines[n-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fenced отвечает, лежит ли строка внутри огороженного блока кода.
|
||||||
|
func (d *Document) Fenced(n int) bool {
|
||||||
|
return n >= 1 && n <= d.lineCount() && d.fence[n-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prose проходит строки тела, не попавшие в огороженные блоки, и отдаёт их с
|
||||||
|
// вырезанным содержимым инлайн-кода. В бэктиках идентификатор стоит как
|
||||||
|
// пример записи, а не как ссылка, — различает их именно разметка.
|
||||||
|
func (d *Document) Prose(yield func(n int, text string) bool) {
|
||||||
|
for n := d.Body; n <= d.lineCount(); n++ {
|
||||||
|
if d.fence[n-1] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !yield(n, StripInline(d.lines[n-1])) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StripInline вырезает содержимое инлайн-кода, оставляя разделители.
|
||||||
|
func StripInline(line string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
inCode := false
|
||||||
|
for _, r := range line {
|
||||||
|
if r == '`' {
|
||||||
|
inCode = !inCode
|
||||||
|
b.WriteRune(' ')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inCode {
|
||||||
|
b.WriteRune(' ')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len возвращает число строк в файле.
|
||||||
|
func (d *Document) Len() int { return len(d.lines) }
|
||||||
|
|
||||||
|
func (d *Document) lineCount() int { return len(d.lines) }
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package doc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Front — шапка файла. Ключей немного и все они плоские, поэтому разбор здесь
|
||||||
|
// свой: тащить YAML ради четырёх строк `ключ: значение` не за что.
|
||||||
|
//
|
||||||
|
// Ось слоя объявляется здесь ключами lang и stack, а не выводится из пути
|
||||||
|
// (META-38); отсутствие обоих означает базовый слой темы.
|
||||||
|
type Front struct {
|
||||||
|
Topic string
|
||||||
|
Prefix string
|
||||||
|
Lang string
|
||||||
|
Stack string
|
||||||
|
Extends string
|
||||||
|
|
||||||
|
// At — номер строки, на которой объявлен ключ; нужен, чтобы находка
|
||||||
|
// показывала на объявление, а не на начало файла.
|
||||||
|
At map[string]int
|
||||||
|
// Unknown — ключи, которых модель не знает.
|
||||||
|
Unknown []string
|
||||||
|
// End — номер строки закрывающего разделителя. Тело файла начинается
|
||||||
|
// со следующей.
|
||||||
|
End int
|
||||||
|
// Present — была ли шапка вообще.
|
||||||
|
Present bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Axis отвечает, объявлена ли у слоя ось. Слой без ключей оси — базовый.
|
||||||
|
func (f Front) Axis() bool {
|
||||||
|
return f.Lang != "" || f.Stack != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFront разбирает шапку из строк файла. Возвращает шапку и номер первой
|
||||||
|
// строки тела.
|
||||||
|
func parseFront(lines []string) (Front, int, error) {
|
||||||
|
front := Front{At: make(map[string]int)}
|
||||||
|
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
|
||||||
|
return front, 1, nil
|
||||||
|
}
|
||||||
|
front.Present = true
|
||||||
|
|
||||||
|
for i := 1; i < len(lines); i++ {
|
||||||
|
line := lines[i]
|
||||||
|
num := i + 1
|
||||||
|
if strings.TrimSpace(line) == "---" {
|
||||||
|
front.End = num
|
||||||
|
return front, num + 1, nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, value, ok := strings.Cut(line, ":")
|
||||||
|
if !ok {
|
||||||
|
return front, num, fmt.Errorf("строка %d шапки не имеет вида «ключ: значение»", num)
|
||||||
|
}
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
front.At[key] = num
|
||||||
|
|
||||||
|
switch key {
|
||||||
|
case "topic":
|
||||||
|
front.Topic = value
|
||||||
|
case "prefix":
|
||||||
|
front.Prefix = value
|
||||||
|
case "lang":
|
||||||
|
front.Lang = value
|
||||||
|
case "stack":
|
||||||
|
front.Stack = value
|
||||||
|
case "extends":
|
||||||
|
front.Extends = value
|
||||||
|
default:
|
||||||
|
front.Unknown = append(front.Unknown, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return front, len(lines) + 1, fmt.Errorf("шапка не закрыта разделителем ---")
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
// Package lang держит словари языка конвенций: слова, которыми записаны
|
||||||
|
// модальность правила, метки его блоков и связки сценарного блока.
|
||||||
|
//
|
||||||
|
// Словарь — свойство версии языка и естественного языка набора, а не самого
|
||||||
|
// набора: версия 1 по-русски задаёт один и тот же список слов в любом
|
||||||
|
// репозитории, и повторять его в каждом манифесте незачем. Пока спецификация
|
||||||
|
// языка живёт вместе с каноном, словари лежат здесь; когда она уедет в
|
||||||
|
// отдельный репозиторий со своими файлами словарей, источником станут они, а
|
||||||
|
// форма Vocabulary и все проверки поверх неё останутся прежними.
|
||||||
|
package lang
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Level — ступень шкалы обязательности. Ступеней пять в четырёх категориях
|
||||||
|
// ISO/IEC Directives, Part 2; какими словами они названы — параметр
|
||||||
|
// естественного языка, а сама шкала одна на все словари.
|
||||||
|
type Level int
|
||||||
|
|
||||||
|
const (
|
||||||
|
Requirement Level = iota + 1
|
||||||
|
Prohibition
|
||||||
|
Recommendation
|
||||||
|
RecommendationAgainst
|
||||||
|
Permission
|
||||||
|
)
|
||||||
|
|
||||||
|
// String даёт имя ступени для сообщений об ошибках — не слово словаря, а роль.
|
||||||
|
func (l Level) String() string {
|
||||||
|
switch l {
|
||||||
|
case Requirement:
|
||||||
|
return "требование"
|
||||||
|
case Prohibition:
|
||||||
|
return "запрет"
|
||||||
|
case Recommendation:
|
||||||
|
return "рекомендация"
|
||||||
|
case RecommendationAgainst:
|
||||||
|
return "рекомендация против"
|
||||||
|
case Permission:
|
||||||
|
return "разрешение"
|
||||||
|
}
|
||||||
|
return "неизвестная ступень"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark — метка блока правила. Метки обязательности не задают, а размечают:
|
||||||
|
// что здесь обоснование, что иллюстрация, что запись о механизации, что
|
||||||
|
// заглушка на месте снятого правила.
|
||||||
|
type Mark int
|
||||||
|
|
||||||
|
const (
|
||||||
|
Rationale Mark = iota + 1
|
||||||
|
Examples
|
||||||
|
Mechanized
|
||||||
|
Retired
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m Mark) String() string {
|
||||||
|
switch m {
|
||||||
|
case Rationale:
|
||||||
|
return "обоснование"
|
||||||
|
case Examples:
|
||||||
|
return "примеры"
|
||||||
|
case Mechanized:
|
||||||
|
return "механизация"
|
||||||
|
case Retired:
|
||||||
|
return "снятое правило"
|
||||||
|
}
|
||||||
|
return "неизвестная метка"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connective — служебное слово сценарного блока. В строку о версии языка эти
|
||||||
|
// слова не входят и под проверку «модальные слова вне правил» не подпадают:
|
||||||
|
// обязательности они не задают, только структуру.
|
||||||
|
type Connective int
|
||||||
|
|
||||||
|
const (
|
||||||
|
When Connective = iota + 1
|
||||||
|
Then
|
||||||
|
And
|
||||||
|
Or
|
||||||
|
)
|
||||||
|
|
||||||
|
// Vocabulary — словарь одной версии языка на одном естественном языке.
|
||||||
|
type Vocabulary struct {
|
||||||
|
Version int
|
||||||
|
Code string
|
||||||
|
Modals map[string]Level
|
||||||
|
Marks map[string]Mark
|
||||||
|
Scenario map[string]Connective
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal сообщает ступень слова, если слово принадлежит шкале этого словаря.
|
||||||
|
func (v Vocabulary) Modal(word string) (Level, bool) {
|
||||||
|
l, ok := v.Modals[word]
|
||||||
|
return l, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark сообщает роль метки, если слово принадлежит меткам этого словаря.
|
||||||
|
func (v Vocabulary) Mark(word string) (Mark, bool) {
|
||||||
|
m, ok := v.Marks[word]
|
||||||
|
return m, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Word возвращает слово, которым в этом словаре записана ступень.
|
||||||
|
func (v Vocabulary) Word(l Level) string {
|
||||||
|
for w, got := range v.Modals {
|
||||||
|
if got == l {
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkWord возвращает слово, которым в этом словаре записана метка.
|
||||||
|
func (v Vocabulary) MarkWord(m Mark) string {
|
||||||
|
for w, got := range v.Marks {
|
||||||
|
if got == m {
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lead возвращает слово словаря, которым начинается text, и его длину.
|
||||||
|
// Длиннейшее совпадение выигрывает: «НЕ ДОЛЖЕН» не должен читаться как
|
||||||
|
// «ДОЛЖЕН», а метка с датой («СНЯТО 2026-07-26») — как метка без неё.
|
||||||
|
func (v Vocabulary) Lead(text string) (string, bool) {
|
||||||
|
best := ""
|
||||||
|
for _, w := range v.Words() {
|
||||||
|
if len(w) <= len(best) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !hasWordPrefix(text, w) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
best = w
|
||||||
|
}
|
||||||
|
return best, best != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Words перечисляет все слова словаря, которые язык объявляет в строке о
|
||||||
|
// версии: ступени шкалы и метки. Связки сценария сюда не входят.
|
||||||
|
func (v Vocabulary) Words() []string {
|
||||||
|
words := make([]string, 0, len(v.Modals)+len(v.Marks))
|
||||||
|
for w := range v.Modals {
|
||||||
|
words = append(words, w)
|
||||||
|
}
|
||||||
|
for w := range v.Marks {
|
||||||
|
words = append(words, w)
|
||||||
|
}
|
||||||
|
sort.Strings(words)
|
||||||
|
return words
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasWordPrefix проверяет, что text начинается со слова w и слово на этом
|
||||||
|
// кончается: «ДОЛЖЕНСТВОВАНИЕ» словом ДОЛЖЕН не является.
|
||||||
|
func hasWordPrefix(text, w string) bool {
|
||||||
|
if !strings.HasPrefix(text, w) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
rest := text[len(w):]
|
||||||
|
if rest == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
r, _ := utf8.DecodeRuneInString(rest)
|
||||||
|
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// registry — словари, известные бинарю. Ключ верхнего уровня — версия языка,
|
||||||
|
// вложенный — код естественного языка набора.
|
||||||
|
var registry = map[int]map[string]Vocabulary{
|
||||||
|
1: {
|
||||||
|
"ru": {
|
||||||
|
Version: 1,
|
||||||
|
Code: "ru",
|
||||||
|
Modals: map[string]Level{
|
||||||
|
"ДОЛЖЕН": Requirement,
|
||||||
|
"НЕ ДОЛЖЕН": Prohibition,
|
||||||
|
"СЛЕДУЕТ": Recommendation,
|
||||||
|
"НЕ СЛЕДУЕТ": RecommendationAgainst,
|
||||||
|
"ДОПУСКАЕТСЯ": Permission,
|
||||||
|
},
|
||||||
|
Marks: map[string]Mark{
|
||||||
|
"ПОЧЕМУ": Rationale,
|
||||||
|
"ПРИМЕРЫ": Examples,
|
||||||
|
"МЕХАНИЗИРОВАНО": Mechanized,
|
||||||
|
"СНЯТО": Retired,
|
||||||
|
},
|
||||||
|
Scenario: map[string]Connective{
|
||||||
|
"КОГДА": When,
|
||||||
|
"ТОГДА": Then,
|
||||||
|
"И": And,
|
||||||
|
"ИЛИ": Or,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
Version: 1,
|
||||||
|
Code: "en",
|
||||||
|
Modals: map[string]Level{
|
||||||
|
"MUST": Requirement,
|
||||||
|
"MUST NOT": Prohibition,
|
||||||
|
"SHOULD": Recommendation,
|
||||||
|
"SHOULD NOT": RecommendationAgainst,
|
||||||
|
"MAY": Permission,
|
||||||
|
},
|
||||||
|
Marks: map[string]Mark{
|
||||||
|
"WHY": Rationale,
|
||||||
|
"EXAMPLES": Examples,
|
||||||
|
"MECHANIZED": Mechanized,
|
||||||
|
"RETIRED": Retired,
|
||||||
|
},
|
||||||
|
Scenario: map[string]Connective{
|
||||||
|
"WHEN": When,
|
||||||
|
"THEN": Then,
|
||||||
|
"AND": And,
|
||||||
|
"OR": Or,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup выдаёт словарь версии языка на указанном естественном языке.
|
||||||
|
func Lookup(version int, code string) (Vocabulary, error) {
|
||||||
|
byCode, ok := registry[version]
|
||||||
|
if !ok {
|
||||||
|
return Vocabulary{}, fmt.Errorf("версия языка %d инструменту неизвестна, известны: %s", version, versions())
|
||||||
|
}
|
||||||
|
v, ok := byCode[code]
|
||||||
|
if !ok {
|
||||||
|
return Vocabulary{}, fmt.Errorf("словарь %q для версии языка %d инструменту неизвестен, известны: %s", code, version, codes(version))
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Foreign перечисляет слова чужих словарей той же версии — те, по которым
|
||||||
|
// видно смесь словарей. Слова, совпадающие с собственными, отброшены.
|
||||||
|
func Foreign(version int, code string) map[string]string {
|
||||||
|
own, err := Lookup(version, code)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
mine := make(map[string]bool)
|
||||||
|
for _, w := range own.Words() {
|
||||||
|
mine[w] = true
|
||||||
|
}
|
||||||
|
foreign := make(map[string]string)
|
||||||
|
for otherCode, other := range registry[version] {
|
||||||
|
if otherCode == code {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, w := range other.Words() {
|
||||||
|
if !mine[w] {
|
||||||
|
foreign[w] = otherCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return foreign
|
||||||
|
}
|
||||||
|
|
||||||
|
func versions() string {
|
||||||
|
out := make([]string, 0, len(registry))
|
||||||
|
for v := range registry {
|
||||||
|
out = append(out, fmt.Sprint(v))
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return strings.Join(out, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func codes(version int) string {
|
||||||
|
out := make([]string, 0, len(registry[version]))
|
||||||
|
for c := range registry[version] {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return strings.Join(out, ", ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package lang_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/convy/internal/lang"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLeadTakesLongestMatch(t *testing.T) {
|
||||||
|
v, err := lang.Lookup(1, "ru")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
text string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"ДОЛЖЕН.", "ДОЛЖЕН"},
|
||||||
|
{"НЕ ДОЛЖЕН.", "НЕ ДОЛЖЕН"},
|
||||||
|
{"НЕ СЛЕДУЕТ.", "НЕ СЛЕДУЕТ"},
|
||||||
|
{"СЛЕДУЕТ.", "СЛЕДУЕТ"},
|
||||||
|
{"СНЯТО 2026-07-26.", "СНЯТО"},
|
||||||
|
{"ПОЧЕМУ.", "ПОЧЕМУ"},
|
||||||
|
{"", ""},
|
||||||
|
{"Заголовок правила", ""},
|
||||||
|
{"ДОЛЖЕНСТВОВАНИЕ", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
got, ok := v.Lead(tc.text)
|
||||||
|
if tc.want == "" {
|
||||||
|
if ok {
|
||||||
|
t.Errorf("Lead(%q) = %q, ждали, что слово не найдётся", tc.text, got)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !ok || got != tc.want {
|
||||||
|
t.Errorf("Lead(%q) = %q, %v; ждали %q", tc.text, got, ok, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForeignExcludesOwnWords(t *testing.T) {
|
||||||
|
foreign := lang.Foreign(1, "ru")
|
||||||
|
if len(foreign) == 0 {
|
||||||
|
t.Fatal("для русского словаря не нашлось ни одного чужого слова")
|
||||||
|
}
|
||||||
|
if code, ok := foreign["MUST"]; !ok || code != "en" {
|
||||||
|
t.Errorf("MUST должно опознаваться как слово словаря en, получили %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)
|
||||||
|
}
|
||||||
|
if _, own := v.Mark(word); own {
|
||||||
|
t.Errorf("метка %q собственная, а попала в чужие", word)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownVersionAndCode(t *testing.T) {
|
||||||
|
if _, err := lang.Lookup(99, "ru"); err == nil {
|
||||||
|
t.Error("неизвестная версия языка принята без ошибки")
|
||||||
|
}
|
||||||
|
if _, err := lang.Lookup(1, "xx"); err == nil {
|
||||||
|
t.Error("неизвестный словарь принят без ошибки")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// Package manifest читает suite.toml — манифест набора конвенций.
|
||||||
|
//
|
||||||
|
// Манифест объявляет три вещи: язык, которым записаны правила набора, живые и
|
||||||
|
// выбывшие темы, живые и выбывшие префиксы правил вместе с путями к файлам.
|
||||||
|
// Инструмент не знает ни одной темы и ни одного префикса заранее — весь этот
|
||||||
|
// список приходит отсюда.
|
||||||
|
package manifest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/BurntSushi/toml"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Name — имя манифеста набора. По тому, какой из двух манифестов лежит рядом,
|
||||||
|
// определяется контекст: suite.toml — набор, .conventions.toml — проект.
|
||||||
|
const Name = "suite.toml"
|
||||||
|
|
||||||
|
// DefaultLanguageCode — естественный язык набора, когда манифест о нём молчит.
|
||||||
|
// Ключ необязателен намеренно: словарь живёт в бинаре, и заставлять каждый
|
||||||
|
// набор объявлять то, что и так подразумевается, незачем.
|
||||||
|
const DefaultLanguageCode = "ru"
|
||||||
|
|
||||||
|
// Language — секция [language]: версия языка конвенций и два документа о нём.
|
||||||
|
// Полное описание остаётся у автора набора, короткое едет в копию.
|
||||||
|
type Language struct {
|
||||||
|
Version int `toml:"version"`
|
||||||
|
Lang string `toml:"lang"`
|
||||||
|
Description string `toml:"description"`
|
||||||
|
Reading string `toml:"reading"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section — раздел манифеста, разбитый на живую и выбывшую части. Выбывшее
|
||||||
|
// хранится, а не удаляется: имя темы и префикс правила живут в чужих
|
||||||
|
// репозиториях, и переиспользовать их нельзя никогда.
|
||||||
|
type Section struct {
|
||||||
|
Live map[string]string `toml:"live"`
|
||||||
|
Retired map[string]string `toml:"retired"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manifest — разобранный suite.toml.
|
||||||
|
type Manifest struct {
|
||||||
|
Language Language `toml:"language"`
|
||||||
|
Topics Section `toml:"topics"`
|
||||||
|
Prefixes Section `toml:"prefixes"`
|
||||||
|
|
||||||
|
// Path — путь, по которому манифест прочитан.
|
||||||
|
Path string `toml:"-"`
|
||||||
|
// Undecoded — ключи, которых инструмент не знает. Опечатка в манифесте
|
||||||
|
// иначе прошла бы молча, а стоит она подписки или целого файла.
|
||||||
|
Undecoded []string `toml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load читает манифест набора из директории 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
var m Manifest
|
||||||
|
meta, err := toml.Decode(string(data), &m)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("разбор %s: %w", path, err)
|
||||||
|
}
|
||||||
|
m.Path = path
|
||||||
|
for _, key := range meta.Undecoded() {
|
||||||
|
m.Undecoded = append(m.Undecoded, key.String())
|
||||||
|
}
|
||||||
|
sort.Strings(m.Undecoded)
|
||||||
|
|
||||||
|
if m.Language.Lang == "" {
|
||||||
|
m.Language.Lang = DefaultLanguageCode
|
||||||
|
}
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find поднимается от start вверх до корня, ища директорию с манифестом
|
||||||
|
// набора. Так `convy suite check` работает из любой поддиректории набора.
|
||||||
|
func Find(start string) (string, error) {
|
||||||
|
dir, err := filepath.Abs(start)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, Name)); err == nil {
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
return "", ErrNotFound
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrNotFound означает, что рядом и выше нет манифеста набора.
|
||||||
|
var ErrNotFound = errors.New("манифест набора не найден")
|
||||||
|
|
||||||
|
// LivePrefixes перечисляет живые префиксы в порядке, устойчивом между
|
||||||
|
// запусками: вывод проверки не должен зависеть от обхода карты.
|
||||||
|
func (m *Manifest) LivePrefixes() []string {
|
||||||
|
return sortedKeys(m.Prefixes.Live)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LiveTopics перечисляет живые темы в устойчивом порядке.
|
||||||
|
func (m *Manifest) LiveTopics() []string {
|
||||||
|
return sortedKeys(m.Topics.Live)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrefixOf возвращает префикс, объявленный за файлом, если такой есть.
|
||||||
|
// Путь сверяется в форме со слэшами — так он записан в манифесте.
|
||||||
|
func (m *Manifest) PrefixOf(path string) (string, bool) {
|
||||||
|
want := filepath.ToSlash(path)
|
||||||
|
for prefix, declared := range m.Prefixes.Live {
|
||||||
|
if filepath.ToSlash(declared) == want {
|
||||||
|
return prefix, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// PathOf возвращает путь, объявленный за живым префиксом.
|
||||||
|
func (m *Manifest) PathOf(prefix string) (string, bool) {
|
||||||
|
path, ok := m.Prefixes.Live[prefix]
|
||||||
|
return path, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// TopicLive отвечает, объявлена ли тема среди живых.
|
||||||
|
func (m *Manifest) TopicLive(topic string) bool {
|
||||||
|
_, ok := m.Topics.Live[topic]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// TopicRetired отвечает, значится ли тема среди выбывших.
|
||||||
|
func (m *Manifest) TopicRetired(topic string) bool {
|
||||||
|
_, ok := m.Topics.Retired[topic]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrefixRetired отвечает, значится ли префикс среди выбывших.
|
||||||
|
func (m *Manifest) PrefixRetired(prefix string) bool {
|
||||||
|
_, ok := m.Prefixes.Retired[prefix]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidPrefix проверяет форму префикса: четыре заглавные латинские буквы.
|
||||||
|
// Буква X в начале зарезервирована за репозиториями-потребителями, и набор
|
||||||
|
// её не занимает никогда.
|
||||||
|
func ValidPrefix(prefix string) error {
|
||||||
|
if len(prefix) != 4 {
|
||||||
|
return fmt.Errorf("префикс %q — не четыре буквы", prefix)
|
||||||
|
}
|
||||||
|
for _, r := range prefix {
|
||||||
|
if r < 'A' || r > 'Z' {
|
||||||
|
return fmt.Errorf("префикс %q содержит не заглавную латинскую букву", prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(prefix, "X") {
|
||||||
|
return fmt.Errorf("префикс %q начинается на X — буква зарезервирована за локальными правилами потребителей", prefix)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedKeys(m map[string]string) []string {
|
||||||
|
keys := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// Package suite собирает набор конвенций в память: манифест, словарь языка и
|
||||||
|
// документы, которые язык употребляет.
|
||||||
|
//
|
||||||
|
// Проверке подлежит всё, что язык употребляет: файлы конвенций и документ,
|
||||||
|
// которым набор ведёт себя сам. Список этих файлов даёт манифест — раздел
|
||||||
|
// живых префиксов, где у каждого префикса записан путь. Файл, который язык
|
||||||
|
// только цитирует (описание языка), в наборе не значится и проверок не
|
||||||
|
// получает.
|
||||||
|
package suite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/convy/internal/doc"
|
||||||
|
"git.vakhrushev.me/av/convy/internal/lang"
|
||||||
|
"git.vakhrushev.me/av/convy/internal/manifest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Suite — загруженный набор.
|
||||||
|
type Suite struct {
|
||||||
|
Root string
|
||||||
|
Manifest *manifest.Manifest
|
||||||
|
Vocab lang.Vocabulary
|
||||||
|
|
||||||
|
// Docs — документы набора в порядке живых префиксов.
|
||||||
|
Docs []*doc.Document
|
||||||
|
// ByPrefix — документ по префиксу из манифеста.
|
||||||
|
ByPrefix map[string]*doc.Document
|
||||||
|
// Missing — префиксы, чей файл манифест объявляет, а файловой системы в
|
||||||
|
// нём нет.
|
||||||
|
Missing map[string]string
|
||||||
|
// Unregistered — найденные в наборе файлы с шапкой, которых манифест не
|
||||||
|
// объявляет. Такой файл не проверяется и не собирается: для набора его
|
||||||
|
// нет, хотя автор считает иначе.
|
||||||
|
Unregistered []string
|
||||||
|
// Broken — файлы, чью шапку не удалось разобрать.
|
||||||
|
Broken []error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load читает набор из директории root.
|
||||||
|
func Load(root string) (*Suite, error) {
|
||||||
|
m, err := manifest.Load(root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
vocab, err := lang.Lookup(m.Language.Version, m.Language.Lang)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", m.Path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &Suite{
|
||||||
|
Root: root,
|
||||||
|
Manifest: m,
|
||||||
|
Vocab: vocab,
|
||||||
|
ByPrefix: make(map[string]*doc.Document),
|
||||||
|
Missing: make(map[string]string),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, prefix := range m.LivePrefixes() {
|
||||||
|
rel := filepath.ToSlash(m.Prefixes.Live[prefix])
|
||||||
|
name := filepath.Join(root, filepath.FromSlash(rel))
|
||||||
|
d, err := doc.Load(rel, name)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
s.Missing[prefix] = rel
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.Broken = append(s.Broken, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d.Blocks(vocab)
|
||||||
|
s.Docs = append(s.Docs, d)
|
||||||
|
s.ByPrefix[prefix] = d
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.findUnregistered(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findUnregistered обходит набор и ищет файлы, записанные языком конвенций, но
|
||||||
|
// не объявленные в манифесте. Признак — ключ prefix в шапке, а не
|
||||||
|
// расположение файла: таксономию набор перестраивает, а шапка утверждает.
|
||||||
|
func (s *Suite) findUnregistered() error {
|
||||||
|
declared := make(map[string]bool)
|
||||||
|
for _, path := range s.Manifest.Prefixes.Live {
|
||||||
|
declared[filepath.ToSlash(path)] = true
|
||||||
|
}
|
||||||
|
for _, path := range s.Manifest.Prefixes.Retired {
|
||||||
|
declared[filepath.ToSlash(path)] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
err := filepath.WalkDir(s.Root, func(name string, entry fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if entry.IsDir() {
|
||||||
|
if strings.HasPrefix(entry.Name(), ".") && name != s.Root {
|
||||||
|
return fs.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if filepath.Ext(entry.Name()) != ".md" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(s.Root, name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel = filepath.ToSlash(rel)
|
||||||
|
if declared[rel] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
d, err := doc.Load(rel, name)
|
||||||
|
if err != nil {
|
||||||
|
// Шапки у файла нет или она сломана — для набора это не
|
||||||
|
// документ языка, а просто markdown рядом.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if d.Front.Prefix != "" {
|
||||||
|
s.Unregistered = append(s.Unregistered, rel)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("обход набора: %w", err)
|
||||||
|
}
|
||||||
|
sort.Strings(s.Unregistered)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conventions отбирает документы конвенций — те, что несут тему и потому
|
||||||
|
// уезжают к потребителю. Документ без темы (набор ведёт им себя сам) проверки
|
||||||
|
// распространения не получает.
|
||||||
|
func (s *Suite) Conventions() []*doc.Document {
|
||||||
|
var out []*doc.Document
|
||||||
|
for _, d := range s.Docs {
|
||||||
|
if d.Front.Topic != "" {
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layers перечисляет слои темы — документы, объявившие это имя в шапке.
|
||||||
|
func (s *Suite) Layers(topic string) []*doc.Document {
|
||||||
|
var out []*doc.Document
|
||||||
|
for _, d := range s.Docs {
|
||||||
|
if d.Front.Topic == topic {
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefix возвращает префикс, объявленный манифестом за документом.
|
||||||
|
func (s *Suite) Prefix(d *doc.Document) string {
|
||||||
|
prefix, _ := s.Manifest.PrefixOf(d.Path)
|
||||||
|
return prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exists проверяет, есть ли в наборе файл по пути от корня.
|
||||||
|
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