Skip to content
24 changes: 17 additions & 7 deletions cmd/mdsmith/buildpass.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
package main

import (
"cmp"
"context"
"fmt"
"io"
"os"
"sort"
"slices"
"strings"
"time"

Expand Down Expand Up @@ -681,15 +682,24 @@ func collectBuildTargets(
f, _ := lint.NewFile(path, src) // NewFile never errors; goldmark always produces an AST
targets = append(targets, targetsFromFile(f, root, recipeFilter)...)
}
sort.SliceStable(targets, func(i, j int) bool {
if targets[i].file != targets[j].file {
return targets[i].file < targets[j].file
}
return targets[i].line < targets[j].line
})
sortBuildTargets(targets)
return targets, errs
}

// sortBuildTargets orders targets by file, then line, in place.
// slices.SortStableFunc compares the concrete buildTarget values
// directly, unlike sort.SliceStable, which drives reflect.Swapper
// under the hood — see docs/development/high-performance-go.md's
// "reflect in hot paths" anti-pattern.
func sortBuildTargets(targets []buildTarget) {
slices.SortStableFunc(targets, func(a, b buildTarget) int {
return cmp.Or(
cmp.Compare(a.file, b.file),
cmp.Compare(a.line, b.line),
)
})
}

// targetsFromFile walks one parsed file's <?build?> marker pairs and
// returns a buildTarget per well-formed directive. Directives that fail
// the minimal recipe/outputs precondition are skipped silently — the
Expand Down
24 changes: 17 additions & 7 deletions cmd/mdsmith/deps.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
package main

import (
"cmp"
"encoding/json"
"fmt"
"io"
"os"
"sort"
"slices"

flag "github.com/spf13/pflag"

Expand Down Expand Up @@ -99,15 +100,24 @@ func collectDeps(idx *index.Index, target string, incoming bool) []depRecord {
Target: edgeTargetString(e, target),
})
}
sort.SliceStable(recs, func(a, b int) bool {
if recs[a].Line != recs[b].Line {
return recs[a].Line < recs[b].Line
}
return recs[a].Target < recs[b].Target
})
sortDepRecords(recs)
return recs
}

// sortDepRecords orders recs by line, then target, in place.
// slices.SortStableFunc compares the concrete depRecord values
// directly, unlike sort.SliceStable, which drives reflect.Swapper
// under the hood — see docs/development/high-performance-go.md's
// "reflect in hot paths" anti-pattern.
func sortDepRecords(recs []depRecord) {
slices.SortStableFunc(recs, func(a, b depRecord) int {
return cmp.Or(
cmp.Compare(a.Line, b.Line),
cmp.Compare(a.Target, b.Target),
)
})
}

// emitDeps writes records to w. Exit code: 0 when records were
// emitted, 1 when none, 2 on unknown format or write error.
func emitDeps(w io.Writer, recs []depRecord, format string) int {
Expand Down
21 changes: 17 additions & 4 deletions cmd/mdsmith/rename.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
package main

import (
"cmp"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"slices"
"strings"
"sync"

Expand Down Expand Up @@ -378,9 +379,7 @@ func applyEdits(src []byte, edits []refactor.Edit) ([]byte, error) {
if cr {
row = seg[:len(seg)-1]
}
sort.SliceStable(es, func(i, j int) bool {
return es[i].Range.Start.Character > es[j].Range.Start.Character
})
sortEditsByCharacterDesc(es)
buf := append([]byte(nil), row...)
for _, e := range es {
s := mdtext.UTF16ToByteOffset(row, e.Range.Start.Character)
Expand All @@ -402,6 +401,20 @@ func applyEdits(src []byte, edits []refactor.Edit) ([]byte, error) {
return joinLF(segs), nil
}

// sortEditsByCharacterDesc orders es by descending Start.Character in
// place (rightmost edit first), so applyEdits can splice each edit
// into the line without its offset shifting from an earlier splice.
// slices.SortStableFunc compares the concrete refactor.Edit values
// directly, unlike sort.SliceStable, which drives reflect.Swapper
// under the hood — see docs/development/high-performance-go.md's
// "reflect in hot paths" anti-pattern. Stability preserves the
// original order among edits reported at the same offset.
func sortEditsByCharacterDesc(es []refactor.Edit) {
slices.SortStableFunc(es, func(a, b refactor.Edit) int {
return cmp.Compare(b.Range.Start.Character, a.Range.Start.Character)
})
}

// splitKeepCR splits src on `\n`, keeping any trailing `\r` on each
// segment so CRLF endings survive a round-trip.
func splitKeepCR(src []byte) [][]byte {
Expand Down
102 changes: 102 additions & 0 deletions cmd/mdsmith/sortnoreflect_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package main

import (
"testing"

buildexec "github.com/jeduden/mdsmith/internal/build"
"github.com/jeduden/mdsmith/internal/refactor"
)

// These pin the allocation cost of the CLI output sorts that used
// sort.Slice / sort.SliceStable, which drive reflect.Swapper
// internally — the "reflect in hot paths" anti-pattern in
// docs/development/high-performance-go.md, already fixed the same
// way elsewhere in this codebase (astutil.sortSectionHeadings,
// mdsmith.sortDirEntries). Each sorts a command-scoped result list
// once per CLI invocation, not per workspace file, so the win is
// small but free and matches the project's own established
// convention. slices.SortFunc on the concrete result type sorts with
// no reflection. The backlink-record sort now lives in
// internal/backlinks (see its sortnoreflect_test.go) after that
// algorithm moved out of cmd/mdsmith.

func TestSortEditsByCharacterDesc_NoReflectSort(t *testing.T) {
if testing.Short() {
t.Skip("alloc gate skipped in -short mode")
}
if raceEnabled {
t.Skip("alloc gate skipped under -race")
}
es := []refactor.Edit{
{Range: refactor.Range{Start: refactor.Position{Character: 3}}},
{Range: refactor.Range{Start: refactor.Position{Character: 9}}},
{Range: refactor.Range{Start: refactor.Position{Character: 1}}},
}
sortEditsByCharacterDesc(es)
if es[0].Range.Start.Character != 9 || es[2].Range.Start.Character != 1 {
t.Fatalf("sortEditsByCharacterDesc did not sort descending: %v", es)
}

const runs = 200
allocs := testing.AllocsPerRun(runs, func() {
sortEditsByCharacterDesc(es)
})
t.Logf("sortEditsByCharacterDesc allocs/op = %.0f", allocs)
if allocs > 0 {
t.Fatalf("sortEditsByCharacterDesc allocs/op = %.0f, want 0 (no reflection)", allocs)
}
}

func TestSortDepRecords_NoReflectSort(t *testing.T) {
if testing.Short() {
t.Skip("alloc gate skipped in -short mode")
}
if raceEnabled {
t.Skip("alloc gate skipped under -race")
}
recs := []depRecord{
{Line: 5, Target: "b.md"},
{Line: 2, Target: "z.md"},
{Line: 2, Target: "a.md"},
}
sortDepRecords(recs)
if recs[0].Line != 2 || recs[0].Target != "a.md" || recs[2].Line != 5 {
t.Fatalf("sortDepRecords did not sort: %v", recs)
}

const runs = 200
allocs := testing.AllocsPerRun(runs, func() {
sortDepRecords(recs)
})
t.Logf("sortDepRecords allocs/op = %.0f", allocs)
if allocs > 0 {
t.Fatalf("sortDepRecords allocs/op = %.0f, want 0 (no reflection)", allocs)
}
}

func TestSortBuildTargets_NoReflectSort(t *testing.T) {
if testing.Short() {
t.Skip("alloc gate skipped in -short mode")
}
if raceEnabled {
t.Skip("alloc gate skipped under -race")
}
targets := []buildTarget{
{file: "z.md", line: 1, target: buildexec.Target{}},
{file: "a.md", line: 5, target: buildexec.Target{}},
{file: "a.md", line: 2, target: buildexec.Target{}},
}
sortBuildTargets(targets)
if targets[0].file != "a.md" || targets[0].line != 2 || targets[2].file != "z.md" {
t.Fatalf("sortBuildTargets did not sort: %v", targets)
}

const runs = 200
allocs := testing.AllocsPerRun(runs, func() {
sortBuildTargets(targets)
})
t.Logf("sortBuildTargets allocs/op = %.0f", allocs)
if allocs > 0 {
t.Fatalf("sortBuildTargets allocs/op = %.0f, want 0 (no reflection)", allocs)
}
}
24 changes: 17 additions & 7 deletions internal/backlinks/backlinks.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
package backlinks

import (
"cmp"
"fmt"
"path"
"path/filepath"
"sort"
"slices"
"strings"

"github.com/jeduden/mdsmith/internal/bytelimit"
Expand Down Expand Up @@ -109,15 +110,24 @@ func Collect(
}
records = append(records, rs...)
}
sort.SliceStable(records, func(i, j int) bool {
if records[i].Source != records[j].Source {
return records[i].Source < records[j].Source
}
return records[i].Line < records[j].Line
})
sortBacklinkRecords(records)
return records, errs
}

// sortBacklinkRecords orders records by source path, then line, in
// place. slices.SortStableFunc compares the concrete Record values
// directly, unlike sort.SliceStable, which drives reflect.Swapper
// under the hood — see docs/development/high-performance-go.md's
// "reflect in hot paths" anti-pattern.
func sortBacklinkRecords(records []Record) {
slices.SortStableFunc(records, func(a, b Record) int {
return cmp.Or(
cmp.Compare(a.Source, b.Source),
cmp.Compare(a.Line, b.Line),
)
})
}

// extractBacklinksFromSource reads one source file, parses it, and
// returns the backlink records (and any read/parse error) for links
// that resolve to wantTarget. wantAnchorSlug is the already-slugified
Expand Down
12 changes: 12 additions & 0 deletions internal/backlinks/race_off_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//go:build !race

package backlinks

// raceEnabled is the build-tag sentinel for `-race`. Under the default
// build (no race), it is false. The race_on_test.go variant flips it
// under `-race`. Allocation-budget tests key off this constant to skip
// when the race detector is instrumenting allocations: the detector's
// bookkeeping adds enough extra allocations to make the per-op count
// flaky at the edge of a tight budget, and the budget is for
// production behaviour, not race-instrumented test runs.
const raceEnabled = false
9 changes: 9 additions & 0 deletions internal/backlinks/race_on_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//go:build race

package backlinks

// raceEnabled is the build-tag sentinel for `-race`. See the
// race_off_test.go variant for the rationale; this file is selected
// when the race detector is active, so allocation-budget tests skip
// instead of fighting the detector's allocation bookkeeping.
const raceEnabled = true
37 changes: 37 additions & 0 deletions internal/backlinks/sortnoreflect_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package backlinks

import "testing"

// TestSortBacklinkRecords_NoReflectSort pins the allocation cost of
// the backlink-record sort. It used sort.SliceStable, which drives
// reflect.Swapper internally — the "reflect in hot paths"
// anti-pattern in docs/development/high-performance-go.md.
// slices.SortStableFunc on the concrete Record type sorts with no
// reflection. This sort ran in cmd/mdsmith before the algorithm was
// extracted into this package.
func TestSortBacklinkRecords_NoReflectSort(t *testing.T) {
if testing.Short() {
t.Skip("alloc gate skipped in -short mode")
}
if raceEnabled {
t.Skip("alloc gate skipped under -race")
}
records := []Record{
{Source: "z.md", Line: 1},
{Source: "a.md", Line: 5},
{Source: "a.md", Line: 2},
}
sortBacklinkRecords(records)
if records[0].Source != "a.md" || records[0].Line != 2 || records[2].Source != "z.md" {
t.Fatalf("sortBacklinkRecords did not sort: %v", records)
}

const runs = 200
allocs := testing.AllocsPerRun(runs, func() {
sortBacklinkRecords(records)
})
t.Logf("sortBacklinkRecords allocs/op = %.0f", allocs)
if allocs > 0 {
t.Fatalf("sortBacklinkRecords allocs/op = %.0f, want 0 (no reflection)", allocs)
}
}
Loading
Loading