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 = "" docsEndMarker = "" ) // 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/, leaving operator prose outside the marker // pair untouched. The marker pair is `` / // ``, 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 }