Files
convy/internal/check/form.go
T
av b2d07ae55d комментарии и сообщения переведены на английский
- комментарии, тексты ошибок, вывод CLI и сообщения тестов теперь на английском
- по-русски остались только литералы словаря ru и содержимое фикстур: это
  данные под проверкой, а не текст инструмента
- согласование числительных в итоге упростилось до английского plural
2026-07-27 10:04:27 +03:00

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