// Package lang holds the vocabularies of the conventions language: the words // that spell out a rule's modality, the marks of its blocks, and the // connectives of a scenario block. // // A vocabulary belongs to a language version and to the suite's natural // language, not to the suite itself: version 1 in Russian names the same words // in every repository, and repeating that list in every manifest buys nothing. // While the language specification lives together with the canon, the // vocabularies live here; once it moves to its own repository with vocabulary // files of its own, those become the source, and the Vocabulary type together // with every check built on it stays as it is. package lang import ( "fmt" "sort" "strings" "unicode" "unicode/utf8" ) // Level is a step on the scale of obligation. There are five steps in the four // categories of ISO/IEC Directives, Part 2; which words name them is a // parameter of the natural language, while the scale is one for all // vocabularies. type Level int const ( Requirement Level = iota + 1 Prohibition Recommendation RecommendationAgainst Permission ) // String names the step for diagnostics — the role, not the vocabulary word. func (l Level) String() string { switch l { case Requirement: return "requirement" case Prohibition: return "prohibition" case Recommendation: return "recommendation" case RecommendationAgainst: return "recommendation against" case Permission: return "permission" } return "unknown level" } // levelNames are the language-neutral handles of the steps: the categories of // the standard rather than the words of any one natural language. A caller // naming a step says "requirement" and gets ДОЛЖЕН or MUST depending on the // suite — which is what keeps an agent out of the business of knowing Russian. var levelNames = []struct { name string level Level }{ {"requirement", Requirement}, {"prohibition", Prohibition}, {"recommendation", Recommendation}, {"not-recommended", RecommendationAgainst}, {"permission", Permission}, } // LevelByName resolves the handle of a step. func LevelByName(name string) (Level, bool) { for _, n := range levelNames { if n.name == name { return n.level, true } } return 0, false } // LevelNames lists the handles in the order of the scale. func LevelNames() []string { out := make([]string, len(levelNames)) for i, n := range levelNames { out[i] = n.name } return out } // Mark labels a block of a rule. Marks set no obligation, they only say what // this is: a rationale, an illustration, a note about mechanization, a stub in // place of a retired rule. type Mark int const ( Rationale Mark = iota + 1 Examples Mechanized Retired ) func (m Mark) String() string { switch m { case Rationale: return "rationale" case Examples: return "examples" case Mechanized: return "mechanized" case Retired: return "retired" } return "unknown mark" } // Connective is a service word of a scenario block. Such words stay out of the // language version line and out of the "modal words outside rules" check: they // set no obligation, only structure. type Connective int const ( When Connective = iota + 1 Then And Or ) // Vocabulary is the vocabulary of one language version in one natural language. type Vocabulary struct { Version int Code string Modals map[string]Level Marks map[string]Mark Scenario map[string]Connective // Line is the language version line, with a single verb for the version // number. Every convention names the language by one such line, and the // wording around the words is as much a property of the natural language // as the words themselves — which is why it lives here and not in a // template inside the command that writes a new file. Line string } // VersionLine renders the language version line for this vocabulary. func (v Vocabulary) VersionLine() string { return fmt.Sprintf(v.Line, v.Version) } // Modal reports the step of a word if the word belongs to this scale. func (v Vocabulary) Modal(word string) (Level, bool) { l, ok := v.Modals[word] return l, ok } // Mark reports the role of a mark if the word belongs to these marks. func (v Vocabulary) Mark(word string) (Mark, bool) { m, ok := v.Marks[word] return m, ok } // Word returns the word this vocabulary uses for a step. func (v Vocabulary) Word(l Level) string { for w, got := range v.Modals { if got == l { return w } } return "" } // MarkWord returns the word this vocabulary uses for a mark. func (v Vocabulary) MarkWord(m Mark) string { for w, got := range v.Marks { if got == m { return w } } return "" } // Lead returns the vocabulary word that opens text. The longest match wins: // "MUST NOT" must not be read as "MUST", and a mark carrying a date // ("RETIRED 2026-07-26") must not be read as a different mark. func (v Vocabulary) Lead(text string) (string, bool) { best := "" for _, w := range v.Words() { if len(w) <= len(best) { continue } if !hasWordPrefix(text, w) { continue } best = w } return best, best != "" } // Words lists every word the language names in its version line: the steps of // the scale and the marks. Scenario connectives are not among them. func (v Vocabulary) Words() []string { words := make([]string, 0, len(v.Modals)+len(v.Marks)) for w := range v.Modals { words = append(words, w) } for w := range v.Marks { words = append(words, w) } sort.Strings(words) return words } // hasWordPrefix reports whether text starts with word w and the word ends // there: "MUSTARD" is not the word "MUST". func hasWordPrefix(text, w string) bool { if !strings.HasPrefix(text, w) { return false } rest := text[len(w):] if rest == "" { return true } r, _ := utf8.DecodeRuneInString(rest) return !unicode.IsLetter(r) && !unicode.IsDigit(r) } // registry holds the vocabularies known to the binary. The outer key is the // language version, the inner one the code of the suite's natural language. var registry = map[int]map[string]Vocabulary{ 1: { "ru": { Version: 1, Code: "ru", Modals: map[string]Level{ "ДОЛЖЕН": Requirement, "НЕ ДОЛЖЕН": Prohibition, "СЛЕДУЕТ": Recommendation, "НЕ СЛЕДУЕТ": RecommendationAgainst, "ДОПУСКАЕТСЯ": Permission, }, Marks: map[string]Mark{ "ПОЧЕМУ": Rationale, "ПРИМЕРЫ": Examples, "МЕХАНИЗИРОВАНО": Mechanized, "СНЯТО": Retired, }, Scenario: map[string]Connective{ "КОГДА": When, "ТОГДА": Then, "И": And, "ИЛИ": Or, }, Line: "Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки\n" + "ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке\n" + "конвенций версии %d — тогда и только тогда, когда написаны заглавными.", }, "en": { Version: 1, Code: "en", Modals: map[string]Level{ "MUST": Requirement, "MUST NOT": Prohibition, "SHOULD": Recommendation, "SHOULD NOT": RecommendationAgainst, "MAY": Permission, }, Marks: map[string]Mark{ "WHY": Rationale, "EXAMPLES": Examples, "MECHANIZED": Mechanized, "RETIRED": Retired, }, Scenario: map[string]Connective{ "WHEN": When, "THEN": Then, "AND": And, "OR": Or, }, Line: "The key words MUST, MUST NOT, SHOULD, SHOULD NOT, MAY and the marks WHY,\n" + "EXAMPLES, MECHANIZED and RETIRED are to be interpreted as described in the\n" + "conventions language, version %d, and only when written in capitals.", }, }, } // Lookup returns the vocabulary of a language version in the given natural // language. func Lookup(version int, code string) (Vocabulary, error) { byCode, ok := registry[version] if !ok { return Vocabulary{}, fmt.Errorf("language version %d is unknown to the tool; known versions: %s", version, versions()) } v, ok := byCode[code] if !ok { return Vocabulary{}, fmt.Errorf("vocabulary %q of language version %d is unknown to the tool; known: %s", code, version, codes(version)) } return v, nil } // Recognize picks the vocabulary a text is written in by the words it names. // // A copy in a consuming repository declares its language by the version line // and by nothing else: no manifest travels with it, and no path back to the // suite is written anywhere in it. So a check run outside a suite learns which // words are normative the same way a reader does — off the line the document // carries. The version number decides between vocabularies naming the same // words, which is what two versions of one natural language would do. func Recognize(text string) (Vocabulary, bool) { var best Vocabulary for _, version := range sortedVersions() { for _, code := range sortedCodes(version) { v := registry[version][code] if !namesAll(text, v.Words()) { continue } if best.Version == 0 || matchesVersion(text, v.Version) { best = v } } } return best, best.Version != 0 } func namesAll(text string, words []string) bool { for _, w := range words { if !strings.Contains(text, w) { return false } } return true } // matchesVersion looks for the number as a whole word, so that version 1 is not // read out of "version 12". func matchesVersion(text string, version int) bool { number := fmt.Sprint(version) for i := 0; ; { j := strings.Index(text[i:], number) if j < 0 { return false } start, end := i+j, i+j+len(number) before := start == 0 || !isDigit(rune(text[start-1])) after := end == len(text) || !isDigit(rune(text[end])) if before && after { return true } i = end if i >= len(text) { return false } } } func isDigit(r rune) bool { return r >= '0' && r <= '9' } func sortedVersions() []int { out := make([]int, 0, len(registry)) for v := range registry { out = append(out, v) } sort.Ints(out) return out } func sortedCodes(version int) []string { out := make([]string, 0, len(registry[version])) for c := range registry[version] { out = append(out, c) } sort.Strings(out) return out } // Foreign lists the words of the other vocabularies of the same version — the // ones that give away a mixture of vocabularies. Words that coincide with the // suite's own are dropped. func Foreign(version int, code string) map[string]string { own, err := Lookup(version, code) if err != nil { return nil } mine := make(map[string]bool) for _, w := range own.Words() { mine[w] = true } foreign := make(map[string]string) for otherCode, other := range registry[version] { if otherCode == code { continue } for _, w := range other.Words() { if !mine[w] { foreign[w] = otherCode } } } return foreign } // ForeignConnectives lists the scenario connectives of the other vocabularies // of the same version. They are kept apart from Foreign because they are caught // differently: a connective is a whole word of everyday speech in some // language — AND and OR are SQL keywords too — so only its position at the // start of a line tells a scenario block from a mention. func ForeignConnectives(version int, code string) map[string]string { own, err := Lookup(version, code) if err != nil { return nil } foreign := make(map[string]string) for otherCode, other := range registry[version] { if otherCode == code { continue } for w := range other.Scenario { if _, mine := own.Scenario[w]; mine { continue } foreign[w] = otherCode } } return foreign } func versions() string { out := make([]string, 0, len(registry)) for v := range registry { out = append(out, fmt.Sprint(v)) } sort.Strings(out) return strings.Join(out, ", ") } func codes(version int) string { out := make([]string, 0, len(registry[version])) for c := range registry[version] { out = append(out, c) } sort.Strings(out) return strings.Join(out, ", ") }