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, ", ") }