suite check: реализована проверка целостности набора
- разбор документа по языку конвенций: шапка, области правил, блоки под метками - проверки формы правила, распространения, ссылок и самого suite.toml - словарь языка живёт в бинаре реестром «версия × естественный язык», темы и префиксы берутся только из манифеста
This commit is contained in:
@@ -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, ", ")
|
||||
}
|
||||
Reference in New Issue
Block a user