suite init и suite add: заведение набора и конвенции в двух режимах

- suite init создаёт директорию и манифест со скелетом таблиц; suite add пишет
  файл конвенции и вправляет запись в suite.toml, сохраняя комментарии
- без аргументов команды спрашивают поля с подсказками, с флагами берут всё
  сразу и не спрашивают ничего; без терминала пустой вызов отказывает
- строка о версии языка генерируется из словаря набора, поэтому созданный файл
  проходит suite check без правок
- починено разрешение extends: короткая форма бралась по суффиксу и могла
  указать на сам файл; теперь неоднозначность либо избегается при записи, либо
  сообщается ошибкой
This commit is contained in:
av
2026-07-27 10:40:57 +03:00
parent 709157237d
commit b29b5b5e6f
9 changed files with 1247 additions and 21 deletions
+51 -10
View File
@@ -3,6 +3,7 @@ package check
import (
"path"
"regexp"
"sort"
"strings"
"git.vakhrushev.me/av/convy/internal/doc"
@@ -74,7 +75,13 @@ func checkExtends(s *suite.Suite, d *doc.Document, rep *Report) {
return
}
at := d.Front.At["extends"]
target := resolveExtends(s, d.Front.Extends)
target, others := resolveExtends(s, d, d.Front.Extends)
if len(others) > 1 {
rep.Errorf(Spread, d.Path, at,
"extends points at %q, and the suite holds several files it could mean (%v): give the path from the root of the suite",
d.Front.Extends, others)
return
}
if target == nil {
rep.Errorf(Spread, d.Path, at,
"extends points at %q, and the suite holds no such file", d.Front.Extends)
@@ -91,17 +98,52 @@ func checkExtends(s *suite.Suite, d *doc.Document, rep *Report) {
}
}
// resolveExtends looks up the document at the path written in extends. The path
// is given from the conventions directory, so both it and a path from the root
// of the suite are tried.
func resolveExtends(s *suite.Suite, ref string) *doc.Document {
// resolveExtends looks up the document at the path written in extends.
//
// The path may be given from the root of the suite or from the directory of
// conventions, so an exact match is tried first and a tail match second. A tail
// match can fit several files at once — two layers of one topic often share a
// file name — so every candidate is returned and the caller reports the
// ambiguity instead of picking by the order documents happen to be loaded in.
// The document doing the extending is never its own base.
func resolveExtends(s *suite.Suite, from *doc.Document, ref string) (*doc.Document, []string) {
ref = path.Clean(strings.TrimPrefix(ref, "./"))
var candidates []*doc.Document
for _, d := range s.Docs {
if d.Path == ref || strings.HasSuffix(d.Path, "/"+ref) {
return d
if d == from {
continue
}
if d.Path == ref {
return d, []string{d.Path}
}
if strings.HasSuffix(d.Path, "/"+ref) {
candidates = append(candidates, d)
}
}
return nil
paths := make([]string, len(candidates))
for i, d := range candidates {
paths[i] = d.Path
}
sort.Strings(paths)
if len(candidates) == 1 {
return candidates[0], paths
}
return nil, paths
}
// namesSuiteFile reports whether a candidate written in the text names a file
// the suite holds. Unlike an extends key it needs no single answer: a path that
// fits several files of the canon is a path all the same.
func namesSuiteFile(s *suite.Suite, candidate string) bool {
if s.Exists(candidate) {
return true
}
for _, d := range s.Docs {
if d.Path == candidate || strings.HasSuffix(d.Path, "/"+candidate) {
return true
}
}
return false
}
// checkMechanized looks for the mark of mechanization in the text of a
@@ -142,8 +184,7 @@ func checkCanonPaths(s *suite.Suite, d *doc.Document, rep *Report) {
continue
}
for _, candidate := range mdPathRe.FindAllString(d.Line(n), -1) {
target := resolveExtends(s, candidate)
if target == nil && !s.Exists(candidate) {
if !namesSuiteFile(s, candidate) {
continue
}
rep.Errorf(Spread, d.Path, n,