400 lines
10 KiB
Go
400 lines
10 KiB
Go
// SPDX-Licence-Identifier: EUPL-1.2
|
|
package session
|
|
|
|
import (
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"path"
|
|
"regexp"
|
|
"slices"
|
|
"testing"
|
|
|
|
core "dappco.re/go/core"
|
|
)
|
|
|
|
var testNamePattern = regexp.MustCompile(`^Test[A-Za-z0-9]+_[A-Za-z0-9]+_(Good|Bad|Ugly)$`)
|
|
|
|
func TestConventions_BannedImports_Good(t *testing.T) {
|
|
files := parseGoFiles(t, ".")
|
|
|
|
banned := map[string]string{
|
|
core.Concat("encoding", "/json"): "use dappco.re/go/core JSON helpers instead",
|
|
core.Concat("error", "s"): "use core.E/op-aware errors instead",
|
|
core.Concat("f", "mt"): "use dappco.re/go/core formatting helpers instead",
|
|
"github.com/pkg/errors": "use coreerr.E(op, msg, err) for package errors",
|
|
core.Concat("o", "s"): "use dappco.re/go/core filesystem helpers instead",
|
|
core.Concat("o", "s/exec"): "use session command helpers or core process abstractions instead",
|
|
core.Concat("path", "/filepath"): "use path or dappco.re/go/core path helpers instead",
|
|
core.Concat("string", "s"): "use dappco.re/go/core string helpers or local helpers instead",
|
|
}
|
|
|
|
for _, file := range files {
|
|
for _, spec := range file.ast.Imports {
|
|
importPath := trimQuotes(spec.Path.Value)
|
|
if core.HasPrefix(importPath, "forge.lthn.ai/") {
|
|
t.Errorf("%s imports %q; use dappco.re/go/core/... paths instead", file.path, importPath)
|
|
continue
|
|
}
|
|
if reason, ok := banned[importPath]; ok {
|
|
t.Errorf("%s imports %q; %s", file.path, importPath, reason)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestConventions_ErrorHandling_Good(t *testing.T) {
|
|
files := parseGoFiles(t, ".")
|
|
|
|
for _, file := range files {
|
|
if core.HasSuffix(file.path, "_test.go") {
|
|
continue
|
|
}
|
|
|
|
ast.Inspect(file.ast, func(node ast.Node) bool {
|
|
call, ok := node.(*ast.CallExpr)
|
|
if !ok {
|
|
return true
|
|
}
|
|
|
|
sel, ok := call.Fun.(*ast.SelectorExpr)
|
|
if !ok {
|
|
return true
|
|
}
|
|
|
|
pkg, ok := sel.X.(*ast.Ident)
|
|
if !ok {
|
|
return true
|
|
}
|
|
|
|
switch {
|
|
case pkg.Name == "core" && sel.Sel.Name == "NewError":
|
|
t.Errorf("%s uses core.NewError; use core.E(op, msg, err)", file.path)
|
|
case pkg.Name == "fmt" && sel.Sel.Name == "Errorf":
|
|
t.Errorf("%s uses fmt.Errorf; use core.E(op, msg, err)", file.path)
|
|
case pkg.Name == "errors" && sel.Sel.Name == "New":
|
|
t.Errorf("%s uses errors.New; use core.E(op, msg, err)", file.path)
|
|
}
|
|
|
|
return true
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConventions_TestNaming_Good(t *testing.T) {
|
|
files := parseGoFiles(t, ".")
|
|
|
|
for _, file := range files {
|
|
if !core.HasSuffix(file.path, "_test.go") {
|
|
continue
|
|
}
|
|
|
|
for _, decl := range file.ast.Decls {
|
|
fn, ok := decl.(*ast.FuncDecl)
|
|
if !ok || fn.Recv != nil {
|
|
continue
|
|
}
|
|
if !core.HasPrefix(fn.Name.Name, "Test") || fn.Name.Name == "TestMain" {
|
|
continue
|
|
}
|
|
if !isTestingTFunc(file, fn) {
|
|
continue
|
|
}
|
|
expectedPrefix := core.Concat("Test", testFileToken(file.path), "_")
|
|
if !core.HasPrefix(fn.Name.Name, expectedPrefix) {
|
|
t.Errorf("%s contains %s; expected prefix %s", file.path, fn.Name.Name, expectedPrefix)
|
|
continue
|
|
}
|
|
if !testNamePattern.MatchString(fn.Name.Name) {
|
|
t.Errorf("%s contains %s; expected TestFile_Function_Good/Bad/Ugly", file.path, fn.Name.Name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestConventions_UsageComments_Good(t *testing.T) {
|
|
files := parseGoFiles(t, ".")
|
|
|
|
for _, file := range files {
|
|
if core.HasSuffix(file.path, "_test.go") {
|
|
continue
|
|
}
|
|
|
|
for _, decl := range file.ast.Decls {
|
|
switch d := decl.(type) {
|
|
case *ast.FuncDecl:
|
|
if d.Recv != nil || !d.Name.IsExported() {
|
|
continue
|
|
}
|
|
text := commentText(d.Doc)
|
|
if !hasDocPrefix(text, d.Name.Name) || !hasUsageExample(text) {
|
|
t.Errorf("%s: exported function %s needs a usage comment starting with %s and containing Example:", file.path, d.Name.Name, d.Name.Name)
|
|
}
|
|
case *ast.GenDecl:
|
|
for i, spec := range d.Specs {
|
|
switch s := spec.(type) {
|
|
case *ast.TypeSpec:
|
|
if !s.Name.IsExported() {
|
|
continue
|
|
}
|
|
text := commentText(typeDocGroup(d, s, i))
|
|
if !hasDocPrefix(text, s.Name.Name) || !hasUsageExample(text) {
|
|
t.Errorf("%s: exported type %s needs a usage comment starting with %s and containing Example:", file.path, s.Name.Name, s.Name.Name)
|
|
}
|
|
case *ast.ValueSpec:
|
|
doc := valueDocGroup(d, s, i)
|
|
for _, name := range s.Names {
|
|
if !name.IsExported() {
|
|
continue
|
|
}
|
|
text := commentText(doc)
|
|
if !hasDocPrefix(text, name.Name) || !hasUsageExample(text) {
|
|
t.Errorf("%s: exported declaration %s needs a usage comment starting with %s and containing Example:", file.path, name.Name, name.Name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
type parsedFile struct {
|
|
path string
|
|
ast *ast.File
|
|
testingImportNames map[string]struct{}
|
|
hasTestingDotImport bool
|
|
}
|
|
|
|
func parseGoFiles(t *testing.T, dir string) []parsedFile {
|
|
t.Helper()
|
|
|
|
paths := core.PathGlob(path.Join(dir, "*.go"))
|
|
if len(paths) == 0 {
|
|
t.Fatalf("no Go files found in %s", dir)
|
|
}
|
|
|
|
slices.Sort(paths)
|
|
|
|
fset := token.NewFileSet()
|
|
files := make([]parsedFile, 0, len(paths))
|
|
for _, filePath := range paths {
|
|
fileAST, err := parser.ParseFile(fset, filePath, nil, parser.ParseComments)
|
|
if err != nil {
|
|
t.Fatalf("parse %s: %v", filePath, err)
|
|
}
|
|
|
|
testingImportNames, hasTestingDotImport := testingImports(fileAST)
|
|
files = append(files, parsedFile{
|
|
path: path.Base(filePath),
|
|
ast: fileAST,
|
|
testingImportNames: testingImportNames,
|
|
hasTestingDotImport: hasTestingDotImport,
|
|
})
|
|
}
|
|
return files
|
|
}
|
|
|
|
func TestConventions_ParseGoFilesMultiplePackages_Good(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
writeTestFile(t, path.Join(dir, "session.go"), "package session\n")
|
|
writeTestFile(t, path.Join(dir, "session_external_test.go"), "package session_test\n")
|
|
writeTestFile(t, path.Join(dir, "README.md"), "# ignored\n")
|
|
|
|
files := parseGoFiles(t, dir)
|
|
if len(files) != 2 {
|
|
t.Fatalf("expected 2 Go files, got %d", len(files))
|
|
}
|
|
|
|
names := []string{files[0].path, files[1].path}
|
|
slices.Sort(names)
|
|
if names[0] != "session.go" || names[1] != "session_external_test.go" {
|
|
t.Fatalf("unexpected files: %v", names)
|
|
}
|
|
}
|
|
|
|
func TestConventions_IsTestingTFuncAliasedImport_Good(t *testing.T) {
|
|
fileAST, fn := parseTestFunc(t, `
|
|
package session_test
|
|
|
|
import t "testing"
|
|
|
|
func TestConventions_AliasedImportContext_Good(testcase *t.T) {}
|
|
`, "TestConventions_AliasedImportContext_Good")
|
|
|
|
names, hasDotImport := testingImports(fileAST)
|
|
file := parsedFile{
|
|
ast: fileAST,
|
|
testingImportNames: names,
|
|
hasTestingDotImport: hasDotImport,
|
|
}
|
|
|
|
if !isTestingTFunc(file, fn) {
|
|
t.Fatal("expected aliased *testing.T signature to be recognised")
|
|
}
|
|
}
|
|
|
|
func TestConventions_IsTestingTFuncDotImport_Good(t *testing.T) {
|
|
fileAST, fn := parseTestFunc(t, `
|
|
package session_test
|
|
|
|
import . "testing"
|
|
|
|
func TestConventions_DotImportContext_Good(testcase *T) {}
|
|
`, "TestConventions_DotImportContext_Good")
|
|
|
|
names, hasDotImport := testingImports(fileAST)
|
|
file := parsedFile{
|
|
ast: fileAST,
|
|
testingImportNames: names,
|
|
hasTestingDotImport: hasDotImport,
|
|
}
|
|
|
|
if !isTestingTFunc(file, fn) {
|
|
t.Fatal("expected dot-imported *testing.T signature to be recognised")
|
|
}
|
|
}
|
|
|
|
func testingImports(file *ast.File) (map[string]struct{}, bool) {
|
|
names := make(map[string]struct{})
|
|
hasDotImport := false
|
|
|
|
for _, spec := range file.Imports {
|
|
importPath := trimQuotes(spec.Path.Value)
|
|
if importPath != "testing" {
|
|
continue
|
|
}
|
|
if spec.Name == nil {
|
|
names["testing"] = struct{}{}
|
|
continue
|
|
}
|
|
switch spec.Name.Name {
|
|
case ".":
|
|
hasDotImport = true
|
|
case "_":
|
|
continue
|
|
default:
|
|
names[spec.Name.Name] = struct{}{}
|
|
}
|
|
}
|
|
|
|
return names, hasDotImport
|
|
}
|
|
|
|
func isTestingTFunc(file parsedFile, fn *ast.FuncDecl) bool {
|
|
if fn.Type == nil || fn.Type.Params == nil || len(fn.Type.Params.List) != 1 {
|
|
return false
|
|
}
|
|
|
|
param := fn.Type.Params.List[0]
|
|
star, ok := param.Type.(*ast.StarExpr)
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
switch expr := star.X.(type) {
|
|
case *ast.Ident:
|
|
return file.hasTestingDotImport && expr.Name == "T"
|
|
case *ast.SelectorExpr:
|
|
pkg, ok := expr.X.(*ast.Ident)
|
|
if !ok {
|
|
return false
|
|
}
|
|
if expr.Sel.Name != "T" {
|
|
return false
|
|
}
|
|
_, ok = file.testingImportNames[pkg.Name]
|
|
return ok
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func typeDocGroup(decl *ast.GenDecl, spec *ast.TypeSpec, index int) *ast.CommentGroup {
|
|
if spec.Doc != nil {
|
|
return spec.Doc
|
|
}
|
|
if len(decl.Specs) == 1 && index == 0 {
|
|
return decl.Doc
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func valueDocGroup(decl *ast.GenDecl, spec *ast.ValueSpec, index int) *ast.CommentGroup {
|
|
if spec.Doc != nil {
|
|
return spec.Doc
|
|
}
|
|
if len(decl.Specs) == 1 && index == 0 {
|
|
return decl.Doc
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func commentText(group *ast.CommentGroup) string {
|
|
if group == nil {
|
|
return ""
|
|
}
|
|
return core.Trim(group.Text())
|
|
}
|
|
|
|
func hasDocPrefix(text, name string) bool {
|
|
if text == "" || !core.HasPrefix(text, name) {
|
|
return false
|
|
}
|
|
if len(text) == len(name) {
|
|
return true
|
|
}
|
|
|
|
next := text[len(name)]
|
|
return (next < 'A' || next > 'Z') && (next < 'a' || next > 'z') && (next < '0' || next > '9') && next != '_'
|
|
}
|
|
|
|
func hasUsageExample(text string) bool {
|
|
if text == "" {
|
|
return false
|
|
}
|
|
return core.HasPrefix(text, "Example:") || core.Contains(text, "\nExample:")
|
|
}
|
|
|
|
func testFileToken(filePath string) string {
|
|
stem := core.TrimSuffix(path.Base(filePath), "_test.go")
|
|
switch stem {
|
|
case "html":
|
|
return "HTML"
|
|
default:
|
|
if stem == "" {
|
|
return ""
|
|
}
|
|
return core.Concat(core.Upper(stem[:1]), stem[1:])
|
|
}
|
|
}
|
|
|
|
func writeTestFile(t *testing.T, path, content string) {
|
|
t.Helper()
|
|
|
|
writeResult := hostFS.Write(path, content)
|
|
if !writeResult.OK {
|
|
t.Fatalf("write %s: %v", path, resultError(writeResult))
|
|
}
|
|
}
|
|
|
|
func parseTestFunc(t *testing.T, src, name string) (*ast.File, *ast.FuncDecl) {
|
|
t.Helper()
|
|
|
|
fset := token.NewFileSet()
|
|
fileAST, err := parser.ParseFile(fset, "test.go", src, parser.ParseComments)
|
|
if err != nil {
|
|
t.Fatalf("parse test source: %v", err)
|
|
}
|
|
|
|
for _, decl := range fileAST.Decls {
|
|
fn, ok := decl.(*ast.FuncDecl)
|
|
if ok && fn.Name.Name == name {
|
|
return fileAST, fn
|
|
}
|
|
}
|
|
|
|
t.Fatalf("function %s not found", name)
|
|
return nil, nil
|
|
}
|