package check import ( "regexp" "strconv" "strings" "git.vakhrushev.me/av/convy/internal/doc" "git.vakhrushev.me/av/convy/internal/suite" ) // refRe catches a rule identifier: four uppercase Latin letters, a hyphen, a // number and an optional table row number. var refRe = regexp.MustCompile(`\b([A-Z]{4})-(\d+)(?:\.(\d+))?`) // Ref is a reference to a rule found in the text. type Ref struct { Prefix string Num int Sub int Line int Text string } // refsIn collects the references in a range of lines. Inline code is cut out: // inside backticks an identifier stands as a sample of the notation rather than // as a reference to an assertion — otherwise the line "a rule is referred to by // its identifier (`SLOG-27`)" would demand that rule SLOG-27 exist. // // Rule headings are skipped: a heading declares a rule instead of referring to // one, and there is nothing to resolve against the manifest. func refsIn(d *doc.Document, from, to int) []Ref { heading := make(map[int]bool, len(d.Rules)) for _, r := range d.Rules { heading[r.Line] = true } var out []Ref for n := from; n <= to; n++ { if d.Fenced(n) || heading[n] { continue } out = append(out, refsInLine(n, doc.StripInline(d.Line(n)))...) } return out } func refsInLine(n int, text string) []Ref { var out []Ref for _, m := range refRe.FindAllStringSubmatch(text, -1) { num, err := strconv.Atoi(m[2]) if err != nil { continue } ref := Ref{Prefix: m[1], Num: num, Line: n, Text: m[0]} if m[3] != "" { ref.Sub, _ = strconv.Atoi(m[3]) } out = append(out, ref) } return out } // checkLinks verifies that every reference resolves. An unresolved identifier // is always an error: with stubs standing in for retired rules there is no // third outcome — a reference leads either to a rule or to the explanation of // why it was retired (META-31, META-32). func checkLinks(s *suite.Suite, d *doc.Document, rep *Report) { for _, ref := range refsIn(d, d.Body, d.Len()) { if strings.HasPrefix(ref.Prefix, "X") { // A consumer's prefix: the local rules of a foreign repository // are invisible to the suite and are not to be resolved. continue } if s.Manifest.PrefixRetired(ref.Prefix) { rep.Errorf(Links, d.Path, ref.Line, "reference %s points at retired prefix %s", ref.Text, ref.Prefix) continue } target, ok := s.ByPrefix[ref.Prefix] if !ok { if _, declared := s.Manifest.PathOf(ref.Prefix); declared { // The file is declared but was not read — the manifest check // has already said so, and saying it twice helps no one. continue } rep.Errorf(Links, d.Path, ref.Line, "reference %s points at prefix %s, which the suite manifest does not declare", ref.Text, ref.Prefix) continue } rule, ok := ruleByNum(target, ref.Num) if !ok { rep.Errorf(Links, d.Path, ref.Line, "reference %s does not resolve: %s holds no rule numbered %d", ref.Text, target.Path, ref.Num) continue } if ref.Sub > 0 && !mentions(target, rule, ref.Text) { rep.Errorf(Links, d.Path, ref.Line, "reference %s does not resolve: the area of %s holds no such row", ref.Text, rule.ID()) } } } // mentions reports whether the text of a reference occurs inside the area of a // rule. That is how a table row number is checked: the row itself carries it. func mentions(d *doc.Document, rule doc.Rule, text string) bool { for n := rule.Line; n <= rule.End; n++ { if strings.Contains(d.Line(n), text) { return true } } return false } func ruleByNum(d *doc.Document, num int) (doc.Rule, bool) { for _, r := range d.Rules { if r.Num == num { return r, true } } return doc.Rule{}, false }