Add FixFile() to rewrite README entries: capitalize descriptions, add trailing periods, remove author attributions, and sort entries alphabetically within each section. Update parser regex to handle entries with markers between URL and description separator. Fix linter to check first letter (not first character) for capitalization. ~480 entries standardized across the README. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
150 lines
3.7 KiB
Markdown
150 lines
3.7 KiB
Markdown
package linter
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"github.com/veggiemonk/awesome-docker/internal/parser"
|
|
)
|
|
|
|
// Rule identifies a linting rule.
|
|
type Rule string
|
|
|
|
const (
|
|
RuleDescriptionCapital Rule = "description-capital"
|
|
RuleDescriptionPeriod Rule = "description-period"
|
|
RuleSorted Rule = "sorted"
|
|
RuleDuplicateURL Rule = "duplicate-url"
|
|
)
|
|
|
|
// Severity of a lint issue.
|
|
type Severity int
|
|
|
|
const (
|
|
SeverityError Severity = iota
|
|
SeverityWarning
|
|
)
|
|
|
|
// Issue is a single lint problem found.
|
|
type Issue struct {
|
|
Rule Rule
|
|
Severity Severity
|
|
Line int
|
|
Message string
|
|
}
|
|
|
|
func (i Issue) String() string {
|
|
sev := "ERROR"
|
|
if i.Severity == SeverityWarning {
|
|
sev = "WARN"
|
|
}
|
|
return fmt.Sprintf("[%s] line %d: %s (%s)", sev, i.Line, i.Message, i.Rule)
|
|
}
|
|
|
|
// CheckEntry validates a single entry against formatting rules.
|
|
func CheckEntry(e parser.Entry) []Issue {
|
|
var issues []Issue
|
|
|
|
if first, ok := firstLetter(e.Description); ok && !unicode.IsUpper(first) {
|
|
issues = append(issues, Issue{
|
|
Rule: RuleDescriptionCapital,
|
|
Severity: SeverityError,
|
|
Line: e.Line,
|
|
Message: fmt.Sprintf("%q: description should start with a capital letter", e.Name),
|
|
})
|
|
}
|
|
|
|
if len(e.Description) > 0 && !strings.HasSuffix(e.Description, ".") {
|
|
issues = append(issues, Issue{
|
|
Rule: RuleDescriptionPeriod,
|
|
Severity: SeverityError,
|
|
Line: e.Line,
|
|
Message: fmt.Sprintf("%q: description should end with a period", e.Name),
|
|
})
|
|
}
|
|
|
|
return issues
|
|
}
|
|
|
|
// CheckSorted verifies entries are in alphabetical order (case-insensitive).
|
|
func CheckSorted(entries []parser.Entry) []Issue {
|
|
var issues []Issue
|
|
for i := 1; i < len(entries); i++ {
|
|
prev := strings.ToLower(entries[i-1].Name)
|
|
curr := strings.ToLower(entries[i].Name)
|
|
if prev > curr {
|
|
issues = append(issues, Issue{
|
|
Rule: RuleSorted,
|
|
Severity: SeverityError,
|
|
Line: entries[i].Line,
|
|
Message: fmt.Sprintf("%q should come before %q (alphabetical order)", entries[i].Name, entries[i-1].Name),
|
|
})
|
|
}
|
|
}
|
|
return issues
|
|
}
|
|
|
|
// CheckDuplicates finds entries with the same URL across the entire document.
|
|
func CheckDuplicates(entries []parser.Entry) []Issue {
|
|
var issues []Issue
|
|
seen := make(map[string]int) // URL -> first line number
|
|
for _, e := range entries {
|
|
url := strings.TrimRight(e.URL, "/")
|
|
if firstLine, exists := seen[url]; exists {
|
|
issues = append(issues, Issue{
|
|
Rule: RuleDuplicateURL,
|
|
Severity: SeverityError,
|
|
Line: e.Line,
|
|
Message: fmt.Sprintf("duplicate URL %q (first seen at line %d)", e.URL, firstLine),
|
|
})
|
|
} else {
|
|
seen[url] = e.Line
|
|
}
|
|
}
|
|
return issues
|
|
}
|
|
|
|
// firstLetter returns the first unicode letter in s and true, or zero and false if none.
|
|
func firstLetter(s string) (rune, bool) {
|
|
for _, r := range s {
|
|
if unicode.IsLetter(r) {
|
|
return r, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// FixEntry returns a copy of the entry with auto-fixable issues corrected.
|
|
func FixEntry(e parser.Entry) parser.Entry {
|
|
fixed := e
|
|
if len(fixed.Description) > 0 {
|
|
// Capitalize first letter (find it, may not be at index 0)
|
|
runes := []rune(fixed.Description)
|
|
for i, r := range runes {
|
|
if unicode.IsLetter(r) {
|
|
runes[i] = unicode.ToUpper(r)
|
|
break
|
|
}
|
|
}
|
|
fixed.Description = string(runes)
|
|
|
|
// Ensure period at end
|
|
if !strings.HasSuffix(fixed.Description, ".") {
|
|
fixed.Description += "."
|
|
}
|
|
}
|
|
return fixed
|
|
}
|
|
|
|
// SortEntries returns a sorted copy of entries (case-insensitive by Name).
|
|
func SortEntries(entries []parser.Entry) []parser.Entry {
|
|
sorted := make([]parser.Entry, len(entries))
|
|
copy(sorted, entries)
|
|
sort.Slice(sorted, func(i, j int) bool {
|
|
return strings.ToLower(sorted[i].Name) < strings.ToLower(sorted[j].Name)
|
|
})
|
|
return sorted
|
|
}
|