- разбор документа по языку конвенций: шапка, области правил, блоки под метками - проверки формы правила, распространения, ссылок и самого suite.toml - словарь языка живёт в бинаре реестром «версия × естественный язык», темы и префиксы берутся только из манифеста
338 lines
13 KiB
Go
338 lines
13 KiB
Go
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, ", ")
|
|
}
|