143 lines
4.9 KiB
Go
143 lines
4.9 KiB
Go
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,
|
|
)
|
|
}
|