From f81eea24960ed59345b2d74c038ff563ff57bc99 Mon Sep 17 00:00:00 2001 From: Julien Bisconti Date: Tue, 31 Mar 2026 19:36:47 +0200 Subject: [PATCH] feat(linter): add SortFile for sort-only README fixing Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/linter/fixer.go | 81 ++++++++++++++++++++++++++++++++ internal/linter/fixer_test.go | 88 +++++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+) diff --git a/internal/linter/fixer.go b/internal/linter/fixer.go index 02f2944..f135c2c 100644 --- a/internal/linter/fixer.go +++ b/internal/linter/fixer.go @@ -145,3 +145,84 @@ func FixFile(path string) (int, error) { w.WriteString("\n") return fixCount, w.Flush() } + +// SortFile reads the README, sorts entries alphabetically within each section, +// and writes the result back. Unlike FixFile, it does not modify descriptions +// (no capitalization, period, or attribution changes). +func SortFile(path string) (int, error) { + f, err := os.Open(path) + if err != nil { + return 0, err + } + defer f.Close() + + var lines []string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + if err := scanner.Err(); err != nil { + return 0, err + } + + fixCount := 0 + + var headingLines []int + for i, line := range lines { + if sectionHeadingRe.MatchString(line) { + headingLines = append(headingLines, i) + } + } + + for i, headingIdx := range headingLines { + start := headingIdx + 1 + end := len(lines) + if i+1 < len(headingLines) { + end = headingLines[i+1] + } + + var entryPositions []int + var entries []parser.Entry + for lineIdx := start; lineIdx < end; lineIdx++ { + entry, err := parser.ParseEntry(lines[lineIdx], lineIdx+1) + if err != nil { + continue + } + entryPositions = append(entryPositions, lineIdx) + entries = append(entries, entry) + } + if len(entries) == 0 { + continue + } + + sorted := SortEntries(entries) + for j, e := range sorted { + lineIdx := entryPositions[j] + // Use the original Raw line from the sorted entry to preserve formatting + if lines[lineIdx] != e.Raw { + fixCount++ + lines[lineIdx] = e.Raw + } + } + } + + if fixCount == 0 { + return 0, nil + } + + out, err := os.Create(path) + if err != nil { + return 0, err + } + defer out.Close() + + w := bufio.NewWriter(out) + for i, line := range lines { + w.WriteString(line) + if i < len(lines)-1 { + w.WriteString("\n") + } + } + w.WriteString("\n") + return fixCount, w.Flush() +} diff --git a/internal/linter/fixer_test.go b/internal/linter/fixer_test.go index 6872ce7..756485a 100644 --- a/internal/linter/fixer_test.go +++ b/internal/linter/fixer_test.go @@ -139,6 +139,94 @@ Some text here. } } +func TestSortFile(t *testing.T) { + content := `# Awesome Docker + +## Tools + +- [Zebra](https://example.com/zebra) - a tool by [@author](https://github.com/author) +- [Alpha](https://example.com/alpha) - another tool + +## Other + +Some text here. +` + tmp, err := os.CreateTemp("", "readme-sort-*.md") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmp.Name()) + + if _, err := tmp.WriteString(content); err != nil { + t.Fatal(err) + } + tmp.Close() + + count, err := SortFile(tmp.Name()) + if err != nil { + t.Fatal(err) + } + if count == 0 { + t.Fatal("expected fixes, got 0") + } + + data, err := os.ReadFile(tmp.Name()) + if err != nil { + t.Fatal(err) + } + result := string(data) + + // Check sorting: Alpha should come before Zebra + alphaIdx := strings.Index(result, "[Alpha]") + zebraIdx := strings.Index(result, "[Zebra]") + if alphaIdx > zebraIdx { + t.Error("expected Alpha before Zebra after sort") + } + + // SortFile must NOT capitalize descriptions + if strings.Contains(result, "- A tool") { + t.Errorf("SortFile should not capitalize descriptions, got:\n%s", result) + } + + // SortFile must NOT remove attribution + if !strings.Contains(result, "@author") { + t.Errorf("SortFile should preserve attribution, got:\n%s", result) + } + + // SortFile must NOT add periods + if strings.Contains(result, "another tool.") { + t.Errorf("SortFile should not add periods, got:\n%s", result) + } +} + +func TestSortFileIdempotent(t *testing.T) { + content := `# Awesome Docker + +## Tools + +- [Alpha](https://example.com/alpha) - A tool. +- [Bravo](https://example.com/bravo) - B tool. +` + tmp, err := os.CreateTemp("", "readme-sort-idem-*.md") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmp.Name()) + + if _, err := tmp.WriteString(content); err != nil { + t.Fatal(err) + } + tmp.Close() + + count, err := SortFile(tmp.Name()) + if err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("expected no changes on already-sorted file, got %d", count) + } +} + func TestFixFileSortsAcrossBlankLinesAndIsIdempotent(t *testing.T) { content := `# Awesome Docker