suite check: реализована проверка целостности набора
- разбор документа по языку конвенций: шапка, области правил, блоки под метками - проверки формы правила, распространения, ссылок и самого suite.toml - словарь языка живёт в бинаре реестром «версия × естественный язык», темы и префиксы берутся только из манифеста
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
// Package lang держит словари языка конвенций: слова, которыми записаны
|
||||
// модальность правила, метки его блоков и связки сценарного блока.
|
||||
//
|
||||
// Словарь — свойство версии языка и естественного языка набора, а не самого
|
||||
// набора: версия 1 по-русски задаёт один и тот же список слов в любом
|
||||
// репозитории, и повторять его в каждом манифесте незачем. Пока спецификация
|
||||
// языка живёт вместе с каноном, словари лежат здесь; когда она уедет в
|
||||
// отдельный репозиторий со своими файлами словарей, источником станут они, а
|
||||
// форма Vocabulary и все проверки поверх неё останутся прежними.
|
||||
package lang
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Level — ступень шкалы обязательности. Ступеней пять в четырёх категориях
|
||||
// ISO/IEC Directives, Part 2; какими словами они названы — параметр
|
||||
// естественного языка, а сама шкала одна на все словари.
|
||||
type Level int
|
||||
|
||||
const (
|
||||
Requirement Level = iota + 1
|
||||
Prohibition
|
||||
Recommendation
|
||||
RecommendationAgainst
|
||||
Permission
|
||||
)
|
||||
|
||||
// String даёт имя ступени для сообщений об ошибках — не слово словаря, а роль.
|
||||
func (l Level) String() string {
|
||||
switch l {
|
||||
case Requirement:
|
||||
return "требование"
|
||||
case Prohibition:
|
||||
return "запрет"
|
||||
case Recommendation:
|
||||
return "рекомендация"
|
||||
case RecommendationAgainst:
|
||||
return "рекомендация против"
|
||||
case Permission:
|
||||
return "разрешение"
|
||||
}
|
||||
return "неизвестная ступень"
|
||||
}
|
||||
|
||||
// Mark — метка блока правила. Метки обязательности не задают, а размечают:
|
||||
// что здесь обоснование, что иллюстрация, что запись о механизации, что
|
||||
// заглушка на месте снятого правила.
|
||||
type Mark int
|
||||
|
||||
const (
|
||||
Rationale Mark = iota + 1
|
||||
Examples
|
||||
Mechanized
|
||||
Retired
|
||||
)
|
||||
|
||||
func (m Mark) String() string {
|
||||
switch m {
|
||||
case Rationale:
|
||||
return "обоснование"
|
||||
case Examples:
|
||||
return "примеры"
|
||||
case Mechanized:
|
||||
return "механизация"
|
||||
case Retired:
|
||||
return "снятое правило"
|
||||
}
|
||||
return "неизвестная метка"
|
||||
}
|
||||
|
||||
// Connective — служебное слово сценарного блока. В строку о версии языка эти
|
||||
// слова не входят и под проверку «модальные слова вне правил» не подпадают:
|
||||
// обязательности они не задают, только структуру.
|
||||
type Connective int
|
||||
|
||||
const (
|
||||
When Connective = iota + 1
|
||||
Then
|
||||
And
|
||||
Or
|
||||
)
|
||||
|
||||
// Vocabulary — словарь одной версии языка на одном естественном языке.
|
||||
type Vocabulary struct {
|
||||
Version int
|
||||
Code string
|
||||
Modals map[string]Level
|
||||
Marks map[string]Mark
|
||||
Scenario map[string]Connective
|
||||
}
|
||||
|
||||
// Modal сообщает ступень слова, если слово принадлежит шкале этого словаря.
|
||||
func (v Vocabulary) Modal(word string) (Level, bool) {
|
||||
l, ok := v.Modals[word]
|
||||
return l, ok
|
||||
}
|
||||
|
||||
// Mark сообщает роль метки, если слово принадлежит меткам этого словаря.
|
||||
func (v Vocabulary) Mark(word string) (Mark, bool) {
|
||||
m, ok := v.Marks[word]
|
||||
return m, ok
|
||||
}
|
||||
|
||||
// Word возвращает слово, которым в этом словаре записана ступень.
|
||||
func (v Vocabulary) Word(l Level) string {
|
||||
for w, got := range v.Modals {
|
||||
if got == l {
|
||||
return w
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// MarkWord возвращает слово, которым в этом словаре записана метка.
|
||||
func (v Vocabulary) MarkWord(m Mark) string {
|
||||
for w, got := range v.Marks {
|
||||
if got == m {
|
||||
return w
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Lead возвращает слово словаря, которым начинается text, и его длину.
|
||||
// Длиннейшее совпадение выигрывает: «НЕ ДОЛЖЕН» не должен читаться как
|
||||
// «ДОЛЖЕН», а метка с датой («СНЯТО 2026-07-26») — как метка без неё.
|
||||
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 перечисляет все слова словаря, которые язык объявляет в строке о
|
||||
// версии: ступени шкалы и метки. Связки сценария сюда не входят.
|
||||
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 проверяет, что text начинается со слова w и слово на этом
|
||||
// кончается: «ДОЛЖЕНСТВОВАНИЕ» словом ДОЛЖЕН не является.
|
||||
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 — словари, известные бинарю. Ключ верхнего уровня — версия языка,
|
||||
// вложенный — код естественного языка набора.
|
||||
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,
|
||||
},
|
||||
},
|
||||
"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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Lookup выдаёт словарь версии языка на указанном естественном языке.
|
||||
func Lookup(version int, code string) (Vocabulary, error) {
|
||||
byCode, ok := registry[version]
|
||||
if !ok {
|
||||
return Vocabulary{}, fmt.Errorf("версия языка %d инструменту неизвестна, известны: %s", version, versions())
|
||||
}
|
||||
v, ok := byCode[code]
|
||||
if !ok {
|
||||
return Vocabulary{}, fmt.Errorf("словарь %q для версии языка %d инструменту неизвестен, известны: %s", code, version, codes(version))
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// Foreign перечисляет слова чужих словарей той же версии — те, по которым
|
||||
// видно смесь словарей. Слова, совпадающие с собственными, отброшены.
|
||||
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
|
||||
}
|
||||
|
||||
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, ", ")
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package lang_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/convy/internal/lang"
|
||||
)
|
||||
|
||||
func TestLeadTakesLongestMatch(t *testing.T) {
|
||||
v, err := lang.Lookup(1, "ru")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
text string
|
||||
want string
|
||||
}{
|
||||
{"ДОЛЖЕН.", "ДОЛЖЕН"},
|
||||
{"НЕ ДОЛЖЕН.", "НЕ ДОЛЖЕН"},
|
||||
{"НЕ СЛЕДУЕТ.", "НЕ СЛЕДУЕТ"},
|
||||
{"СЛЕДУЕТ.", "СЛЕДУЕТ"},
|
||||
{"СНЯТО 2026-07-26.", "СНЯТО"},
|
||||
{"ПОЧЕМУ.", "ПОЧЕМУ"},
|
||||
{"", ""},
|
||||
{"Заголовок правила", ""},
|
||||
{"ДОЛЖЕНСТВОВАНИЕ", ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got, ok := v.Lead(tc.text)
|
||||
if tc.want == "" {
|
||||
if ok {
|
||||
t.Errorf("Lead(%q) = %q, ждали, что слово не найдётся", tc.text, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !ok || got != tc.want {
|
||||
t.Errorf("Lead(%q) = %q, %v; ждали %q", tc.text, got, ok, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestForeignExcludesOwnWords(t *testing.T) {
|
||||
foreign := lang.Foreign(1, "ru")
|
||||
if len(foreign) == 0 {
|
||||
t.Fatal("для русского словаря не нашлось ни одного чужого слова")
|
||||
}
|
||||
if code, ok := foreign["MUST"]; !ok || code != "en" {
|
||||
t.Errorf("MUST должно опознаваться как слово словаря en, получили %q, %v", code, ok)
|
||||
}
|
||||
v, err := lang.Lookup(1, "ru")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for word := range foreign {
|
||||
if _, own := v.Modal(word); own {
|
||||
t.Errorf("слово %q собственное, а попало в чужие", word)
|
||||
}
|
||||
if _, own := v.Mark(word); own {
|
||||
t.Errorf("метка %q собственная, а попала в чужие", word)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownVersionAndCode(t *testing.T) {
|
||||
if _, err := lang.Lookup(99, "ru"); err == nil {
|
||||
t.Error("неизвестная версия языка принята без ошибки")
|
||||
}
|
||||
if _, err := lang.Lookup(1, "xx"); err == nil {
|
||||
t.Error("неизвестный словарь принят без ошибки")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user