package check import ( "os" "path/filepath" "regexp" "sort" "strings" "git.vakhrushev.me/av/convy/internal/manifest" "git.vakhrushev.me/av/convy/internal/source" "git.vakhrushev.me/av/convy/internal/suite" ) // Suite runs every check of a suite and returns the report. func Suite(s *suite.Suite) *Report { rep := &Report{} checkManifest(s, rep) checkLanguageSource(s, rep) checkTopicNames(s, rep) checkBaseLayers(s, rep) checkSelfGoverning(s, rep) checkReadingVocabulary(s, rep) for _, d := range s.Docs { checkForm(s, d, rep) checkLinks(s, d, rep) if d.Front.Topic != "" { checkSpread(s, d, rep) } } return rep } // checkManifest checks the manifest itself: the shape of prefixes, live and // retired not overlapping, the declared files being present and no undeclared // ones lying around. func checkManifest(s *suite.Suite, rep *Report) { m := s.Manifest for _, key := range m.Undecoded { rep.Warnf(Manifest, manifest.Name, 0, "the key %s is unknown to the tool", key) } // The documents about the language are named relative to the level they // belong to. While that level is the suite, the files have to be here; once // the language lives apart, they are out of reach of a check that runs on // every edit and must not touch the network. if m.Language.Source == "" { for _, name := range []string{m.Language.Description, m.Language.Reading} { if name != "" && !s.Exists(name) { rep.Errorf(Manifest, manifest.Name, 0, "the [language] section declares the document %q, and the file is missing", name) } } } byPath := make(map[string][]string) for _, prefix := range m.LivePrefixes() { if err := manifest.ValidPrefix(prefix); err != nil { rep.Errorf(Manifest, manifest.Name, 0, "%s", err) } if m.PrefixRetired(prefix) { rep.Errorf(Manifest, manifest.Name, 0, "prefix %s is listed both live and retired: a retired one is never reissued", prefix) } path := m.Prefixes.Live[prefix] byPath[path] = append(byPath[path], prefix) } for _, path := range sortedKeys(byPath) { if prefixes := byPath[path]; len(prefixes) > 1 { sort.Strings(prefixes) rep.Errorf(Manifest, manifest.Name, 0, "the file %q has several prefixes declared for it (%v): a prefix belongs to one file", path, prefixes) } } for prefix := range m.Prefixes.Retired { if err := manifest.ValidPrefix(prefix); err != nil { rep.Errorf(Manifest, manifest.Name, 0, "among the retired ones: %s", err) } } for _, prefix := range sortedKeys(s.Missing) { rep.Errorf(Manifest, manifest.Name, 0, "prefix %s is assigned to the file %q, which the suite does not hold", prefix, s.Missing[prefix]) } for _, path := range s.Unregistered { rep.Errorf(Manifest, path, 1, "the file is written in the conventions language yet not declared in the suite manifest: for the suite it does not exist") } for _, err := range s.Broken { rep.Errorf(Manifest, manifest.Name, 0, "%s", err) } for _, topic := range m.LiveTopics() { if m.TopicRetired(topic) { rep.Errorf(Manifest, manifest.Name, 0, "topic %q is listed both live and retired", topic) } if len(s.Layers(topic)) == 0 { rep.Errorf(Manifest, manifest.Name, 0, "topic %q is declared live while the suite holds no layer of it: a topic lives as long as at least one layer does", topic) } } } var ( // topicNameRe is the hard bound: a topic name reaches the file system of // a consumer, so it has to be usable as a file name — Latin letters, // digits and the plain separators. topicNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) // kebabRe is the recommended shape, and only a recommendation. kebabRe = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) ) // checkTopicNames checks the shape of topic names. A name lands both in a // consumer's file system and in their manifest, which is where the hard bound // comes from; lower kebab-case on top of that is a recommendation and speaks as // a warning. func checkTopicNames(s *suite.Suite, rep *Report) { for _, topic := range s.Manifest.LiveTopics() { switch { case !topicNameRe.MatchString(topic): rep.Errorf(Manifest, manifest.Name, 0, "the topic name %q is not usable as a file name: a name is written in Latin letters and travels into the file system of a consumer", topic) case !kebabRe.MatchString(topic): rep.Warnf(Manifest, manifest.Name, 0, "the topic name %q is not lower kebab-case, which is the recommended shape", topic) } } } // checkSelfGoverning guards the document without a topic. // // A topic-less document is the one the suite governs itself by: it travels // nowhere and cannot be subscribed to, so it gets no spread checks. A // convention that lost its topic key looks exactly the same, and that loss is // the expensive one — the file keeps every check of form while quietly dropping // every check about travelling to a consumer. // // Which file it is, the manifest says. Guessing was tried and does not reach: // "there is one of them" and "it has no axis keys" both hold for a suite whose // only topic-less file is a convention with the key knocked out. func checkSelfGoverning(s *suite.Suite, rep *Report) { declared := s.Manifest.Governance if declared != "" && !s.Exists(declared) { rep.Errorf(Manifest, manifest.Name, 0, "governance names %q, and the file is missing", declared) } for _, d := range s.Docs { if d.Front.Topic != "" { if s.Manifest.Governs(d.Path) { rep.Errorf(Manifest, d.Path, d.Front.At["topic"], "the manifest names this file the one the suite governs itself by, and it declares topic %q: such a document belongs to no topic, because nobody may subscribe to it", d.Front.Topic) } continue } switch { case declared == "": rep.Errorf(Manifest, d.Path, 1, "the file declares no topic, and the manifest names no document the suite governs itself by: either the topic key is lost, or the manifest has to say governance = %q", d.Path) case !s.Manifest.Governs(d.Path): rep.Errorf(Manifest, d.Path, 1, "the file declares no topic, while the manifest names %q as the one the suite governs itself by: a convention without a topic has lost the key", declared) case d.Front.Axis() || d.Front.Extends != "": rep.Errorf(Manifest, d.Path, d.Front.At["prefix"], "the document the suite governs itself by carries the keys of a layer: it is nobody's layer, having no topic to be a layer of") } } } // checkReadingVocabulary checks that the short account of the language names // every word of the vocabulary (META-30). // // The full account stays with the author of the suite, while the rules are // applied by the reader of a copy — a person or an agent in a foreign // repository who holds the short one and nothing else. Let the two drift apart // and that reader starts reading the words by an older version: ДОПУСКАЕТСЯ // turns back into an everyday "you may", a departure from ДОЛЖЕН stops // demanding a record. Exactly what the words were introduced for fails, and it // fails in silence. func checkReadingVocabulary(s *suite.Suite, rep *Report) { name := s.Manifest.Language.Reading if s.Manifest.Language.Source != "" { // The account lies at a level of its own, and reaching it costs a // fetch. Checking the integrity of a suite runs on every edit, so it // stays local; the reference itself is checked instead. return } if name == "" { // A suite that has not written the document yet; `suite init` says so // among the next steps, and repeating it on every run is noise. return } body, err := os.ReadFile(filepath.Join(s.Root, filepath.FromSlash(name))) if err != nil { // The missing file is reported by the manifest check already. return } var missing []string for _, word := range s.Vocab.Words() { if !containsWord(string(body), word) { missing = append(missing, word) } } if len(missing) > 0 { rep.Errorf(Form, name, 0, "the short account of the language does not name %s: it is the only key to the text of a rule a reader of a copy holds, and a word missing from it is a word read by whatever version the reader remembers", strings.Join(missing, ", ")) } } // checkBaseLayers checks that a topic holds no more than one layer without axis // keys. The base layer is the only one of its kind: it reaches every copy, and a // second such layer would mean two base texts in one assembled file. func checkBaseLayers(s *suite.Suite, rep *Report) { for _, topic := range s.Manifest.LiveTopics() { var base []string for _, d := range s.Layers(topic) { if !d.Front.Axis() { base = append(base, d.Path) } } if len(base) > 1 { sort.Strings(base) for _, path := range base[1:] { rep.Errorf(Spread, path, 1, "topic %q holds more than one layer without axis keys: the base layer is the only one of its kind, and the candidates are %v", topic, base) } } } } func sortedKeys[V any](m map[string]V) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } sort.Strings(keys) return keys } // checkLanguageSource checks the reference to the level of the language. What // it names cannot be reached without a fetch, and a check of a suite does not // fetch — but a reference that means nothing is caught here rather than at the // first assembly in a foreign repository. func checkLanguageSource(s *suite.Suite, rep *Report) { raw := s.Manifest.Language.Source if raw == "" { return } if _, err := source.Parse(raw); err != nil { rep.Errorf(Manifest, manifest.Name, 0, "[language] source: %s", err) } }