Go 222 lines
package docs
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// Marker spellings for `eeco docs refresh`. Mirrors the `archive` and
// `session` schemes; HTML-comment so a Markdown renderer hides them while
// still treating the body between as content. Fixed in slice 1; a future
// slice can introduce a config knob if a user needs custom markers.
const (
docsStartMarker = "<!-- eeco:docs:start -->"
docsEndMarker = "<!-- eeco:docs:end -->"
)
// RefreshAction names the change Refresh made to the target file.
type RefreshAction string
const (
// RefreshReplaced means the existing in-place docs block was rewritten.
RefreshReplaced RefreshAction = "replaced"
// RefreshAppended means no marker pair was present; a freshly-rendered
// block was appended at EOF. The operator is expected to remove the
// prior in-place content manually.
RefreshAppended RefreshAction = "appended"
// RefreshNoop means the file's bytes did not change (rendered block
// already matched).
RefreshNoop RefreshAction = "noop"
)
// RefreshReport summarises a refresh run.
type RefreshReport struct {
Path string
Action RefreshAction
}
// Refresh re-renders the project-state-derived block of target's file at
// repoRoot/<target-filename>, leaving operator prose outside the marker
// pair untouched. The marker pair is `<!-- eeco:docs:start -->` /
// `<!-- eeco:docs:end -->`, fence-aware like `eeco docs compact`.
//
// Behaviour:
// - Marker pair found: bytes between the markers are replaced with the
// freshly-rendered block extracted from the template.
// - Marker pair absent (legacy scaffold): the freshly-rendered block,
// marker-wrapped, is appended at EOF with a blank-line separator.
// The operator removes the prior in-place block manually.
// - File missing: refuse with a hint pointing to `eeco docs new`.
// - Malformed markers (unmatched, nested, out-of-order): refuse with a
// parse error naming the offending line; the file is not touched.
//
// eeco writes the file but never stages or commits it (Constraint 6).
func Refresh(target Target, repoRoot string, p Params) (RefreshReport, error) {
name := target.Filename()
if name == "" {
return RefreshReport{}, fmt.Errorf("unknown target %q", string(target))
}
if filepath.IsAbs(name) || filepath.Clean(name) != name {
return RefreshReport{}, fmt.Errorf("target filename %q is not a safe relative path", name)
}
full := filepath.Join(repoRoot, name)
existing, err := os.ReadFile(full)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return RefreshReport{Path: name}, fmt.Errorf("%s does not exist; run `eeco docs new %s` first", name, string(target))
}
return RefreshReport{Path: name}, err
}
rendered, err := Render(target, p)
if err != nil {
return RefreshReport{Path: name}, err
}
renderedBlock, err := extractDocsBlock([]byte(rendered))
if err != nil {
return RefreshReport{Path: name}, fmt.Errorf("template for %s has no docs marker pair — refresh cannot operate", string(target))
}
startByte, endByte, found, err := findDocsBlock(existing)
if err != nil {
return RefreshReport{Path: name}, err
}
lines := splitLinesKeepEOL(existing)
newline := dominantNewline(lines)
var out []byte
var action RefreshAction
if found {
var buf bytes.Buffer
buf.Write(existing[:startByte])
buf.WriteString(docsStartMarker)
buf.WriteString(newline)
buf.Write(renderedBlock)
if len(renderedBlock) > 0 && !bytes.HasSuffix(renderedBlock, []byte("\n")) {
buf.WriteString(newline)
}
buf.WriteString(docsEndMarker)
buf.WriteString(newline)
buf.Write(existing[endByte:])
out = buf.Bytes()
action = RefreshReplaced
} else {
var buf bytes.Buffer
buf.Write(existing)
if len(existing) > 0 {
if !bytes.HasSuffix(existing, []byte("\n")) {
buf.WriteString(newline)
}
buf.WriteString(newline)
}
buf.WriteString(docsStartMarker)
buf.WriteString(newline)
buf.Write(renderedBlock)
if len(renderedBlock) > 0 && !bytes.HasSuffix(renderedBlock, []byte("\n")) {
buf.WriteString(newline)
}
buf.WriteString(docsEndMarker)
buf.WriteString(newline)
out = buf.Bytes()
action = RefreshAppended
}
if bytes.Equal(out, existing) {
return RefreshReport{Path: name, Action: RefreshNoop}, nil
}
if err := os.WriteFile(full, out, 0o644); err != nil {
return RefreshReport{Path: name}, err
}
return RefreshReport{Path: name, Action: action}, nil
}
// extractDocsBlock returns the bytes between the docs markers in src,
// exclusive of the marker lines themselves. Returns an error when the
// marker pair is missing or malformed.
func extractDocsBlock(src []byte) ([]byte, error) {
startByte, endByte, found, err := findDocsBlock(src)
if err != nil {
return nil, err
}
if !found {
return nil, fmt.Errorf("no docs markers")
}
// Body begins at the byte after the start-marker line's newline.
startTail := src[startByte:]
startNL := bytes.IndexByte(startTail, '\n')
if startNL < 0 {
return []byte{}, nil
}
bodyStart := startByte + startNL + 1
// Body ends at the newline that terminates the last body line, which
// is the previous newline before the end-marker line. Search inside
// the block, walking back from one byte before its end so the
// end-marker line's own trailing newline (if any) is not picked up.
if endByte <= bodyStart {
return []byte{}, nil
}
prevNL := bytes.LastIndexByte(src[bodyStart:endByte-1], '\n')
if prevNL < 0 {
return []byte{}, nil
}
bodyEnd := bodyStart + prevNL + 1
return src[bodyStart:bodyEnd], nil
}
// findDocsBlock walks src once and returns the byte offsets of the
// start-marker line and the end-marker line for the single eeco docs
// block. Markers inside fenced code blocks are ignored. found=false when
// no markers (or only one) are present at top level. An unmatched,
// nested, or out-of-order marker pair is reported as an error so the
// caller refuses to write rather than guess. Mirrors
// `internal/hooks/sessiondelivery.go:findSessionBlock` byte-for-byte in
// posture; only the marker spellings differ.
func findDocsBlock(src []byte) (startByte, endByte int, found bool, err error) {
lines := splitLinesKeepEOL(src)
inFence := false
startIdx, endIdx := -1, -1
for i, line := range lines {
trimmed := strings.TrimRight(line, "\r\n")
stripped := strings.TrimLeft(trimmed, " \t")
if strings.HasPrefix(stripped, "```") || strings.HasPrefix(stripped, "~~~") {
inFence = !inFence
continue
}
if inFence {
continue
}
marker := strings.TrimSpace(trimmed)
switch marker {
case docsStartMarker:
if startIdx != -1 {
return 0, 0, false, fmt.Errorf("docs line %d: nested start marker (previous still open at line %d)", i+1, startIdx+1)
}
startIdx = i
case docsEndMarker:
if startIdx == -1 {
return 0, 0, false, fmt.Errorf("docs line %d: end marker with no matching start", i+1)
}
endIdx = i
startByte = 0
for j := 0; j < startIdx; j++ {
startByte += len(lines[j])
}
endByte = startByte
for j := startIdx; j <= endIdx; j++ {
endByte += len(lines[j])
}
return startByte, endByte, true, nil
}
}
if startIdx != -1 {
return 0, 0, false, fmt.Errorf("docs line %d: start marker with no matching end", startIdx+1)
}
return 0, 0, false, nil
}