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