Files
ConfigKit/documentation_test.go

152 lines
3.9 KiB
Go

package configkit
import (
"bufio"
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"unicode"
)
var markdownLinkPattern = regexp.MustCompile(`(?m)(?:^|[[:space:]])\[[^\]]*\]\(([^)[:space:]]+)`)
func TestLocalDocumentationLinks(t *testing.T) {
repository, err := filepath.Abs(".")
if err != nil {
t.Fatal(err)
}
var failures []string
err = filepath.WalkDir(repository, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
if entry.Name() == ".git" {
return filepath.SkipDir
}
return nil
}
if filepath.Ext(path) != ".md" {
return nil
}
content, readErr := os.ReadFile(path)
if readErr != nil {
return readErr
}
for _, match := range markdownLinkPattern.FindAllStringSubmatch(string(content), -1) {
target := strings.Trim(match[1], "<>")
if strings.Contains(target, "://") || strings.HasPrefix(target, "mailto:") {
continue
}
filePart, fragment, _ := strings.Cut(target, "#")
decoded, decodeErr := url.PathUnescape(filePart)
if decodeErr != nil {
failures = append(failures, fmt.Sprintf("%s: invalid escaped link %q", relativePath(repository, path), target))
continue
}
linked := path
if decoded != "" {
linked = filepath.Clean(filepath.Join(filepath.Dir(path), filepath.FromSlash(decoded)))
}
_, statErr := os.Stat(linked)
outside := linked != repository && !strings.HasPrefix(linked, repository+string(filepath.Separator))
if os.IsNotExist(statErr) && outside && optionalSiblingSource(repository, linked) {
continue // Recorded sibling-source links are optional in isolated clones.
}
if statErr != nil {
failures = append(failures, fmt.Sprintf("%s: missing local link %q", relativePath(repository, path), target))
continue
}
if fragment != "" && filepath.Ext(linked) == ".md" && !markdownHasFragment(linked, fragment) {
failures = append(failures, fmt.Sprintf("%s: missing fragment in %q", relativePath(repository, path), target))
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
if len(failures) != 0 {
t.Fatalf("documentation link failures:\n%s", strings.Join(failures, "\n"))
}
}
func optionalSiblingSource(repository, linked string) bool {
relative, err := filepath.Rel(filepath.Dir(repository), linked)
if err != nil || relative == "." || filepath.IsAbs(relative) {
return false
}
parts := strings.Split(filepath.ToSlash(relative), "/")
if len(parts) == 0 {
return false
}
switch strings.ToLower(parts[0]) {
case "narratio", "notarius", "weatherreporter", "seriatim":
return true
default:
return false
}
}
func markdownHasFragment(path, wanted string) bool {
file, err := os.Open(path)
if err != nil {
return false
}
defer file.Close()
wanted, err = url.PathUnescape(wanted)
if err != nil {
return false
}
seen := make(map[string]int)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !strings.HasPrefix(line, "#") {
continue
}
heading := strings.TrimSpace(strings.TrimLeft(line, "#"))
base := markdownFragment(heading)
count := seen[base]
seen[base]++
fragment := base
if count > 0 {
fragment = fmt.Sprintf("%s-%d", base, count)
}
if fragment == wanted {
return true
}
}
return false
}
func markdownFragment(heading string) string {
var result strings.Builder
previousSpace := false
for _, character := range strings.ToLower(heading) {
switch {
case unicode.IsLetter(character), unicode.IsDigit(character), character == '_', character == '-':
result.WriteRune(character)
previousSpace = false
case unicode.IsSpace(character):
if result.Len() > 0 && !previousSpace {
result.WriteByte('-')
previousSpace = true
}
}
}
return strings.TrimRight(result.String(), "-")
}
func relativePath(root, path string) string {
relative, err := filepath.Rel(root, path)
if err != nil {
return path
}
return filepath.ToSlash(relative)
}