go-doc/backend/docgen/merge.go

144 lines
4.9 KiB
Go
Raw Permalink Normal View History

2026-10-07 15:03:50 +02:00
package docgen
import (
"fmt"
"regexp"
"strings"
)
// beginPrefix opens the machine-written region of an imported page. Everything
// between begin and EndMarker is replaced on every sync; anything outside it is
// preserved exactly as written.
const beginPrefix = "<!-- go-doc:generated:begin"
// EndMarker closes the machine-written region.
const EndMarker = "<!-- go-doc:generated:end -->"
// stampPattern matches the generated=<time> field of the opening marker. It is
// stripped before comparing an existing page with a freshly rendered one, because
// the timestamp changes on every run and would otherwise make every sync look
// like a real edit.
//
// The field has to be matched all the way to the "-->" that closes the comment:
// Timestamp writes "2006-01-02 15:04 UTC", so a pattern that stopped at the
// first space would leave the time behind and every sync that crossed a minute
// boundary would rewrite all four pages for nothing.
var stampPattern = regexp.MustCompile(`generated=[^\n]*?-->`)
// stampReplacement stands in for the stripped timestamp on both sides of the
// comparison, so an old page and a fresh render still compare equal.
const stampReplacement = "generated=STAMP-->"
// Status describes what Merge did, so callers can log precisely.
type Status string
// Possible merge outcomes.
const (
// StatusCreated means no file existed, so the page was written whole.
StatusCreated Status = "created"
// StatusUpdated means the generated region was replaced in place.
StatusUpdated Status = "updated"
// StatusUnchanged means the generated region already matched.
StatusUnchanged Status = "unchanged"
// StatusSkipped means the file had no generated markers and force was off.
StatusSkipped Status = "skipped"
)
// Block locates the three boundaries of the generated region inside a page:
// the opening marker, the body that follows it, and the byte after the closing
// marker. All offsets are absolute and end-exclusive where it matters, so a
// caller can rebuild a page as prefix + replacement + suffix.
type Block struct {
// Marker is the index of the opening marker.
Marker int
// Body is the index of the first byte after the opening marker's line.
Body int
// End is the index of the first byte after the closing marker.
End int
Found bool
}
// FindBlock locates the generated markers. Both must be present and the opening
// marker must sit at the start of a line, otherwise Found is false — which is
// the signal that a file was authored by hand and must not be clobbered.
func FindBlock(content string) Block {
start := strings.Index(content, beginPrefix)
if start < 0 {
return Block{}
}
// The marker must own its line: either it opens the file or a newline
// precedes it.
if start > 0 && content[start-1] != '\n' {
return Block{}
}
lineEnd := strings.IndexByte(content[start:], '\n')
if lineEnd < 0 {
return Block{}
}
body := start + lineEnd + 1
rest := content[body:]
end := strings.Index(rest, EndMarker)
if end < 0 {
return Block{}
}
return Block{
Marker: start,
Body: body,
End: body + end + len(EndMarker),
Found: true,
}
}
// Merge folds a freshly generated page into the existing file.
//
// Hand-written prose outside the markers survives untouched. A file with no
// markers at all is left alone unless force is set, so importing can never
// destroy a document a human wrote. An identical page is reported unchanged and
// returned verbatim, so a no-op sync does not rewrite the file or churn its
// modification time.
func Merge(existing string, generated string, force bool) (string, Status, error) {
if strings.TrimSpace(existing) == "" {
return generated, StatusCreated, nil
}
block := FindBlock(existing)
if !block.Found {
if !force {
return existing, StatusSkipped, nil
}
return generated, StatusUpdated, nil
}
// The replacement carries its own markers, so the preserved prefix stops
// before the old opening marker rather than after it.
var b strings.Builder
b.WriteString(existing[:block.Marker])
b.WriteString(generated)
b.WriteString(existing[block.End:])
merged := b.String()
if stampPattern.ReplaceAllString(merged, stampReplacement) ==
stampPattern.ReplaceAllString(existing, stampReplacement) {
return existing, StatusUnchanged, nil
}
return merged, StatusUpdated, nil
}
// Wrap surrounds body with the generated markers, stamping provenance so a
// reader can tell which project and which source revision produced the page.
//
// The body is normalised to exactly one trailing newline so the closing marker
// always starts its own line, and no newline is emitted after that marker: the
// marker is the last thing Wrap writes, so replacing a region in place and
// keeping the following bytes of the file cannot double the trailing blank line.
func Wrap(source, signature, body string) string {
return fmt.Sprintf(
"%s source=%s signature=%s generated=%s -->\n%s\n%s",
beginPrefix, source, signature, Timestamp(),
strings.TrimRight(body, "\n"), EndMarker,
)
}