- комментарии, тексты ошибок, вывод CLI и сообщения тестов теперь на английском - по-русски остались только литералы словаря ru и содержимое фикстур: это данные под проверкой, а не текст инструмента - согласование числительных в итоге упростилось до английского plural
340 lines
11 KiB
Go
340 lines
11 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 checks the form of a rule by parsing text. It applies to any file
|
|
// the language employs — both the conventions and the document the suite
|
|
// governs itself by.
|
|
func checkForm(s *suite.Suite, d *doc.Document, rep *Report) {
|
|
prefix := checkFilePrefix(s, d, rep)
|
|
checkHeadings(d, prefix, rep)
|
|
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 reconciles the prefix in the front matter with the manifest
|
|
// and returns the prefix the file is supposed to use.
|
|
func checkFilePrefix(s *suite.Suite, d *doc.Document, rep *Report) string {
|
|
declared, _ := s.Manifest.PrefixOf(d.Path)
|
|
|
|
if !d.Front.Present {
|
|
rep.Errorf(Form, d.Path, 1, "the file has no front matter, while the manifest assigns prefix %s to it", declared)
|
|
return declared
|
|
}
|
|
if d.Front.Prefix == "" {
|
|
rep.Errorf(Form, d.Path, 1, "the front matter carries no prefix key")
|
|
return declared
|
|
}
|
|
at := d.Front.At["prefix"]
|
|
if d.Front.Prefix != declared {
|
|
rep.Errorf(Form, d.Path, at,
|
|
"the front matter declares prefix %s, while the manifest assigns %s to this file", d.Front.Prefix, declared)
|
|
}
|
|
if err := manifest.ValidPrefix(d.Front.Prefix); err != nil {
|
|
rep.Errorf(Form, d.Path, at, "%s", err)
|
|
}
|
|
if s.Manifest.PrefixRetired(d.Front.Prefix) {
|
|
rep.Errorf(Form, d.Path, at, "prefix %s is listed among the retired ones", d.Front.Prefix)
|
|
}
|
|
for _, key := range d.Front.Unknown {
|
|
rep.Warnf(Form, d.Path, d.Front.At[key], "front matter key %q is unknown to the tool", key)
|
|
}
|
|
return declared
|
|
}
|
|
|
|
// checkHeadings checks the form of rule headings: the file's own prefix, the
|
|
// third level, a period after the identifier, a title.
|
|
func checkHeadings(d *doc.Document, prefix string, rep *Report) {
|
|
for _, r := range d.Rules {
|
|
if r.Prefix != prefix {
|
|
rep.Errorf(Form, d.Path, r.Line,
|
|
"the rule heading uses prefix %s, while the file owns %s", r.Prefix, prefix)
|
|
}
|
|
if r.HeadingLevel != 3 {
|
|
rep.Errorf(Form, d.Path, r.Line,
|
|
"the heading of rule %s sits at level %d, while a rule is a third-level heading", r.ID(), r.HeadingLevel)
|
|
}
|
|
if r.Malformed != "" {
|
|
rep.Errorf(Form, d.Path, r.Line, "%s: %s", r.ID(), r.Malformed)
|
|
}
|
|
}
|
|
}
|
|
|
|
// checkNumbering checks that numbering is contiguous: from one up to the
|
|
// highest, with no gaps and no repeats (META-31). A gap is indistinguishable
|
|
// from a typo in a number and from a rule someone forgot to finish — which is
|
|
// why there is never one, and a retired rule stays as a stub.
|
|
func checkNumbering(d *doc.Document, prefix string, rep *Report) {
|
|
seen := make(map[int][]int)
|
|
for _, r := range d.Rules {
|
|
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],
|
|
"number %s is taken twice: lines %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,
|
|
"numbering is not contiguous: the highest number is %d, missing %s — a retired rule stays as a stub instead of disappearing",
|
|
highest, joinInts(gaps))
|
|
}
|
|
}
|
|
|
|
var dateRe = regexp.MustCompile(`\d{4}-\d{2}-\d{2}`)
|
|
|
|
// checkRules checks what a rule is made of: either a norm with a rationale, or
|
|
// the stub of a retired one. Neither the norm nor the rationale is ever deleted
|
|
// (META-8, META-10).
|
|
func checkRules(s *suite.Suite, d *doc.Document, rep *Report) {
|
|
v := s.Vocab
|
|
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,
|
|
"rule %s has neither a norm block nor a %s stub", r.ID(), v.MarkWord(lang.Retired))
|
|
case 1:
|
|
if norms[0].Rest == "" {
|
|
rep.Errorf(Form, d.Path, norms[0].Start,
|
|
"in rule %s the %s mark opens no norm: nothing follows it", r.ID(), norms[0].Word)
|
|
}
|
|
default:
|
|
rep.Errorf(Form, d.Path, norms[1].Start,
|
|
"rule %s holds two norms (%s and %s): a norm is a single statement, otherwise a violation of one half of it has no address",
|
|
r.ID(), norms[0].Word, norms[1].Word)
|
|
}
|
|
|
|
rationale, ok := r.Block(lang.Rationale)
|
|
if !ok {
|
|
rep.Errorf(Form, d.Path, r.Line,
|
|
"rule %s has no %s block: the rationale is mandatory", r.ID(), v.MarkWord(lang.Rationale))
|
|
} else if len(norms) > 0 && rationale.Start < norms[0].Start {
|
|
rep.Errorf(Form, d.Path, rationale.Start,
|
|
"in rule %s the rationale precedes the norm: the order of blocks is norm, %s, %s",
|
|
r.ID(), v.MarkWord(lang.Rationale), v.MarkWord(lang.Examples))
|
|
}
|
|
|
|
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,
|
|
"in rule %s the %s block opens the rule: the order of blocks is norm, %s, %s",
|
|
r.ID(), v.MarkWord(lang.Examples), v.MarkWord(lang.Rationale), v.MarkWord(lang.Examples))
|
|
case ok && rationale.Start > examples.Start:
|
|
rep.Errorf(Form, d.Path, examples.Start,
|
|
"in rule %s the %s block precedes the rationale: the requirement first, then the reason, then the illustration",
|
|
r.ID(), v.MarkWord(lang.Examples))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// checkRetired checks the stub of a retired rule: a date and a reason.
|
|
func checkRetired(d *doc.Document, r doc.Rule, retired doc.Block, rep *Report) {
|
|
if len(r.Norms()) > 0 {
|
|
rep.Errorf(Form, d.Path, retired.Start,
|
|
"retired rule %s still holds a norm block: the stub replaces the norm together with the rationale", r.ID())
|
|
}
|
|
if !dateRe.MatchString(d.Line(retired.Start)) {
|
|
rep.Errorf(Form, d.Path, retired.Start,
|
|
"the stub of rule %s carries no date of retirement", r.ID())
|
|
}
|
|
if strings.TrimSpace(retired.Rest) == "" {
|
|
rep.Errorf(Form, d.Path, retired.Start,
|
|
"the stub of rule %s carries no reason for retirement", r.ID())
|
|
}
|
|
}
|
|
|
|
// checkVersionLine looks for the language version line in the introductory
|
|
// prose and returns the bounds of the paragraph carrying it.
|
|
//
|
|
// The line lists the key words of the suite and carries the rule of capitals
|
|
// itself — which makes it the only place outside rules where modal words are
|
|
// lawful.
|
|
func checkVersionLine(s *suite.Suite, d *doc.Document, rep *Report) (from, to int) {
|
|
p, ok := versionParagraph(s, d)
|
|
if !ok {
|
|
start, _ := d.Preamble()
|
|
rep.Errorf(Form, d.Path, start,
|
|
"the introductory prose holds no language version line: it lists the key words of the suite, and without it a convention in a foreign repository loses the key to its own text")
|
|
return 0, 0
|
|
}
|
|
version := strconv.Itoa(s.Manifest.Language.Version)
|
|
if !containsNumber(p.Text(), version) {
|
|
rep.Errorf(Form, d.Path, p.Start,
|
|
"the language version line does not name version %s declared by the suite manifest", version)
|
|
}
|
|
return p.Start, p.End
|
|
}
|
|
|
|
// versionParagraph looks in the introductory prose for the paragraph carrying
|
|
// the language version line: the one listing every key word of the suite. It
|
|
// reports nothing — checkVersionLine speaks about its absence, and speaking
|
|
// twice helps no one.
|
|
func versionParagraph(s *suite.Suite, d *doc.Document) (doc.Paragraph, bool) {
|
|
from, to := d.Preamble()
|
|
words := s.Vocab.Words()
|
|
for _, p := range d.Paragraphs(from, to) {
|
|
if containsAll(p.Text(), words) {
|
|
return p, true
|
|
}
|
|
}
|
|
return doc.Paragraph{}, false
|
|
}
|
|
|
|
// checkModalsOutside looks for capitalized modal words outside rule areas. An
|
|
// area runs from the heading of a rule to the next heading; everything else is
|
|
// prose, and prose is never a norm.
|
|
func checkModalsOutside(s *suite.Suite, d *doc.Document, versionFrom, versionTo int, rep *Report) {
|
|
words := modalWords(s.Vocab)
|
|
d.Prose(func(n int, text string) bool {
|
|
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,
|
|
"the modal word %s stands outside a rule area: capitalized spelling is normative, and prose cannot hold it", w)
|
|
break
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
|
|
// checkForeignVocabulary looks for words of another vocabulary of the same
|
|
// language version. There is one vocabulary per suite: two ways of writing the
|
|
// same requirement double every check.
|
|
func checkForeignVocabulary(s *suite.Suite, d *doc.Document, rep *Report) {
|
|
foreign := lang.Foreign(s.Manifest.Language.Version, s.Manifest.Language.Lang)
|
|
if len(foreign) == 0 {
|
|
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,
|
|
"the word %s belongs to the %q vocabulary, while the suite declares %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 looks for a word as a whole: "MUSTARD" is not the word "MUST",
|
|
// while "MUST." is.
|
|
func containsWord(text, word string) bool {
|
|
for i := 0; ; {
|
|
j := strings.Index(text[i:], word)
|
|
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, ", ")
|
|
}
|