Files
james.maes 5d862308b5 fix(header): preserve coding equals before headers
Share the managed-header sentinel and preserve-boundary rules across detect and render.

Refs #24

Refs #27
2026-06-04 11:41:39 -05:00

386 lines
16 KiB
Go

// Package render produces the canonical header text for a file and the top-level
// LICENSE / LICENSES/<id>.txt content, and performs the safety-critical byte-level
// file mutation that splices a header into (or out of) a source file.
//
// The header is rendered once as REUSE tags plus (optionally) the standard notice
// block from holder/year/style, then re-wrapped into the file's comment syntax
// (block or line) honoring its preserve-first ordering. The same package owns the
// mutation that inserts the rendered header at the correct offset when absent, or
// replaces a detected header span when relicensing.
//
// WHY mutation lives here, next to rendering: inserting and replacing a header is
// the inverse of rendering it, and both must agree byte-for-byte on the header
// region's shape, line endings, and the blank-line separator. Keeping them in one
// package removes the risk of the producer and the splicer drifting apart, which on
// a tool that rewrites source files is a correctness-and-safety hazard.
//
// SAFETY INVARIANTS (enforced by Splice/Insert/Replace and exercised by tests):
//
// - Bytes outside the header region are never altered. A UTF-8 BOM, a shebang, an
// XML/PHP prolog, an encoding pragma, the file's existing line endings (LF or
// CRLF), and the presence/absence of a trailing newline all survive unchanged.
// - The header is placed after preserve-first constructs the rule marks AFTER and
// before constructs the rule marks BEFORE (a package/module declaration).
// - Replacement uses the detected header's exact byte span; only that span is
// swapped, so relicensing leaves no stacked or contradictory notice behind.
package render
import (
"errors"
"fmt"
"strings"
"github.com/KofTwentyTwo/license-tool/internal/gitutil"
managedheader "github.com/KofTwentyTwo/license-tool/internal/header"
"github.com/KofTwentyTwo/license-tool/internal/model"
)
// errNotImplemented is retained for the one strategy (git year) that depends on a
// frozen-but-stubbed sibling package; every other path is fully implemented.
var errNotImplemented = errors.New("render: not implemented")
// HeaderInput is everything needed to render one file's header.
type HeaderInput struct {
// License is the resolved license (text + standard header + id + category).
License model.License
// Holder is the copyright holder text.
Holder string
// Year is the already-resolved year string (e.g. "2021-2026").
Year string
// Style selects which parts (REUSE tags, notice block) to emit.
Style model.HeaderStyle
// FileType supplies comment delimiters and preserve-first rules for wrapping.
FileType model.FileType
}
// Header renders the comment-wrapped header block for a single file, including the
// trailing blank line separating it from file content but NOT the preserve-first
// prefixes (the applier/Splice splices the result in at the correct offset). The
// output uses LF line endings; the applier normalizes to the file's existing
// endings.
//
// The plaintext content is assembled per style:
//
// - StyleReuse: the two REUSE tags only.
// - StyleNotice: the license's StandardHeader only (when the license defines one).
// - StyleReusePlusNotice: REUSE tags, a blank separator, then the StandardHeader.
//
// The sentinel is always embedded so subsequent runs can replace this block with
// the highest confidence. When a style asks for a notice the license does not
// define, the notice is silently omitted (the REUSE tags still carry the license
// identity), which keeps the renderer license-agnostic.
func Header(in HeaderInput) (string, error) {
body := plaintextHeader(in)
if body == "" {
return "", fmt.Errorf("render: empty header for license %q style %q", in.License.SPDXID, in.Style)
}
if in.FileType.Skip {
return "", fmt.Errorf("render: file type %q is uncommentable (skip)", in.FileType.Name)
}
wrapped := wrapComment(body, in.FileType.CommentStyle)
// A single blank line separates the header from file content. The header text
// ends without a trailing newline; the join below adds exactly one LF after the
// comment block, then one blank line, then the splice point continues content.
return wrapped + "\n\n", nil
}
// plaintextHeader assembles the un-wrapped header lines (sentinel + REUSE tags
// and/or notice) joined with LF, without a trailing newline.
func plaintextHeader(in HeaderInput) string {
var lines []string
// The sentinel rides as the first line so detection finds it before parsing any
// tags. It is comment-wrapped along with everything else.
lines = append(lines, "SPDX-License-Tool: "+managedheader.Sentinel)
emitReuse := in.Style == model.StyleReuse || in.Style == model.StyleReusePlusNotice
emitNotice := in.Style == model.StyleNotice || in.Style == model.StyleReusePlusNotice
if emitReuse {
lines = append(lines, reuseTagLines(in.License, in.Holder, in.Year)...)
}
if emitNotice {
notice := strings.TrimRight(in.License.StandardHeader, "\n")
if notice != "" {
if emitReuse {
lines = append(lines, "")
}
// The copyright attribution leads the notice block so a reader sees who
// holds copyright before the grant text. Empty holder/year are omitted to
// avoid a dangling "Copyright" line.
if attribution := copyrightLine(in.Holder, in.Year); attribution != "" {
lines = append(lines, attribution, "")
}
lines = append(lines, strings.Split(notice, "\n")...)
}
}
// Only the sentinel with nothing substantive (no REUSE tags and no notice) means
// the requested style produced no usable header, e.g. notice-only style on a
// license that defines no standard header. Signal emptiness so Header errors
// rather than writing a meaningless sentinel-only comment.
if len(lines) <= 1 {
return ""
}
return strings.Join(lines, "\n")
}
// copyrightLine builds a "Copyright (c) <year> <holder>" attribution line, omitting
// absent parts. Returns empty when both year and holder are empty.
func copyrightLine(holder, year string) string {
parts := []string{"Copyright (c)"}
if year != "" {
parts = append(parts, year)
}
if holder != "" {
parts = append(parts, holder)
}
if len(parts) == 1 {
return ""
}
return strings.Join(parts, " ")
}
// reuseTagLines returns the two canonical REUSE tag lines.
func reuseTagLines(license model.License, holder, year string) []string {
copyright := strings.TrimSpace(strings.TrimSpace(year) + " " + strings.TrimSpace(holder))
return []string{
"SPDX-FileCopyrightText: " + copyright,
"SPDX-License-Identifier: " + license.SPDXID,
}
}
// REUSETags renders just the REUSE tag lines (SPDX-FileCopyrightText and
// SPDX-License-Identifier) as plain text, before comment wrapping. The two lines
// are LF-joined without a trailing newline.
func REUSETags(license model.License, holder, year string) string {
return strings.Join(reuseTagLines(license, holder, year), "\n")
}
// wrapComment wraps the LF-joined plaintext body in cs's comment syntax. Block
// styles emit the opener on its own line, each body line, then the closer on its
// own line. Line styles prefix every body line; a blank body line gets the prefix
// trimmed of trailing whitespace so we never emit trailing spaces. The returned
// string has no trailing newline (the caller adds the separator).
func wrapComment(body string, cs model.CommentStyle) string {
bodyLines := strings.Split(body, "\n")
var out []string
if cs.Block {
out = append(out, cs.Open)
for _, l := range bodyLines {
// One leading space indents the body under the block opener for the common
// "/*\n text\n*/" shape; a blank line stays blank (no trailing space).
if l == "" {
out = append(out, "")
} else {
out = append(out, " "+l)
}
}
out = append(out, cs.Close)
} else {
prefix := cs.LinePrefix
trimmedPrefix := strings.TrimRight(prefix, " \t")
for _, l := range bodyLines {
if l == "" {
out = append(out, trimmedPrefix)
} else {
out = append(out, prefix+l)
}
}
}
return strings.Join(out, "\n")
}
// LicenseFile renders the full top-level LICENSE file body (the verbatim license
// text) for the given license. The text is returned exactly as vendored, with a
// single trailing newline so the file is POSIX-clean.
func LicenseFile(license model.License) (string, error) {
if license.Text == "" {
return "", fmt.Errorf("render: no vendored text for license %q", license.SPDXID)
}
return ensureTrailingNewline(license.Text), nil
}
// LicensesEntry renders the LICENSES/<id>.txt body for the REUSE layout (the
// verbatim license text under its SPDX id). It is byte-identical to LicenseFile;
// REUSE simply stores the same verbatim text under a per-id path.
func LicensesEntry(license model.License) (string, error) {
return LicenseFile(license)
}
// ensureTrailingNewline returns s with exactly one trailing LF.
func ensureTrailingNewline(s string) string {
return strings.TrimRight(s, "\n") + "\n"
}
// Mutation surface ------------------------------------------------------------
//
// Splice produces the new bytes for a file. It is the single safety-critical entry
// point the applier drives; Insert and Replace are exposed for callers that already
// know which operation applies. All three preserve a BOM, the file's line endings,
// and its trailing-newline state exactly, and never touch bytes outside the header
// region.
// Splice computes the new content for one file. When detected.Present is true it
// REPLACES the detected header span (relicense / refresh); otherwise it INSERTS the
// rendered header at the correct offset after preserve-first prefixes. headerLF is
// the LF-normalized header produced by Header. The returned action is "replace",
// "insert", or "none" (when the existing bytes already match the desired header).
func Splice(content []byte, ft model.FileType, headerLF string, detected model.DetectedHeader) (newContent []byte, action string) {
eol := detectEOL(content)
header := normalizeEOL(headerLF, eol)
if detected.Present {
return replaceSpan(content, header, detected.StartByte, detected.EndByte)
}
return insertAt(content, ft, header, eol)
}
// Insert places header at the correct offset after preserve-first prefixes in a
// file that has no managed header. It is Splice's absent-header path, exposed for
// direct use.
func Insert(content []byte, ft model.FileType, headerLF string) (newContent []byte, action string) {
eol := detectEOL(content)
return insertAt(content, ft, normalizeEOL(headerLF, eol), eol)
}
// Replace swaps the byte span [start,end) for header (relicense / refresh), exposed
// for direct use. start/end come from a DetectedHeader.
func Replace(content []byte, headerLF string, start, end int) (newContent []byte, action string) {
eol := detectEOL(content)
return replaceSpan(content, normalizeEOL(headerLF, eol), start, end)
}
// insertAt computes the preserve-first boundary, then inserts header (already
// EOL-normalized) there. The header string already ends with a blank-line
// separator; we re-normalize that separator to eol so an inserted header on a CRLF
// file does not introduce a lone LF.
func insertAt(content []byte, ft model.FileType, header string, eol string) ([]byte, string) {
at := managedheader.PreserveBoundary(content, ft)
prefix := content[:at]
rest := content[at:]
// If everything before the insertion point ends without a newline (e.g. a
// shebang with no trailing newline, or a BOM-only prefix), the header would be
// glued onto that line. Guard by ensuring the prefix ends with an EOL when it is
// non-empty and not already newline-terminated.
var b []byte
b = append(b, prefix...)
if len(prefix) > 0 && !endsWithNewline(prefix) {
b = append(b, eol...)
}
b = append(b, []byte(header)...)
b = append(b, rest...)
return b, "insert"
}
// replaceSpan swaps content[start:end) for header. The detected span's trailing
// blank-line separator (if the detector included it) is part of [start,end); the
// rendered header carries its own separator, so the result keeps exactly one.
//
// Guards: an out-of-range span is treated as a no-op insert refusal — we never
// slice outside the buffer. When the existing span already equals the new header,
// the action is "none" so apply is idempotent and the diff is empty.
func replaceSpan(content []byte, header string, start, end int) ([]byte, string) {
if start < 0 || end > len(content) || start > end {
// Refuse to mutate on a malformed span rather than risk corruption.
return append([]byte(nil), content...), "none"
}
if string(content[start:end]) == header {
return append([]byte(nil), content...), "none"
}
var b []byte
b = append(b, content[:start]...)
b = append(b, []byte(header)...)
b = append(b, content[end:]...)
return b, "replace"
}
// EOL handling ----------------------------------------------------------------
// detectEOL returns the dominant line ending of content: "\r\n" if the first line
// break is a CRLF, else "\n". Empty or newline-free content defaults to LF.
//
// WHY first-break wins rather than a majority vote: we are reinserting a header at
// the top of the file, and matching the top-of-file convention is what keeps the
// edit invisible to a CRLF-configured editor and to git's autocrlf.
func detectEOL(content []byte) string {
for i := 0; i < len(content); i++ {
if content[i] == '\n' {
if i > 0 && content[i-1] == '\r' {
return "\r\n"
}
return "\n"
}
}
return "\n"
}
// normalizeEOL rewrites every LF in s (which is produced with LF endings) to eol.
// s must not already contain CR characters; Header guarantees LF-only output.
func normalizeEOL(s, eol string) string {
if eol == "\n" {
return s
}
return strings.ReplaceAll(s, "\n", eol)
}
// endsWithNewline reports whether b ends with an LF (covers CRLF too, since CRLF
// ends in LF).
func endsWithNewline(b []byte) bool {
return len(b) > 0 && b[len(b)-1] == '\n'
}
// Year resolution -------------------------------------------------------------
// NewYearResolver returns a model.YearResolver for spec. For YearGit it consults
// git history (via internal/gitutil) for the first-commit year; for the explicit
// kinds it formats the stored values; for current it uses the injected nowYear.
func NewYearResolver(spec model.YearSpec) model.YearResolver {
return yearResolver{spec: spec}
}
// yearResolver is the concrete model.YearResolver returned by NewYearResolver.
type yearResolver struct {
spec model.YearSpec
}
// Resolve implements model.YearResolver.
//
// - YearCurrent: the injected nowYear, as "YYYY".
// - YearExplicit: the stored Start, as "YYYY".
// - YearRange: "Start-End" (collapsed to a single year when Start == End).
// - YearGit: "<first-commit-year>-<nowYear>" from gitutil.FirstCommitYear,
// collapsed to a single year when the first commit is the current year.
//
// WHY nowYear is injected: "current" must be deterministic in tests and stable
// within a single run, so the caller owns the clock rather than this package.
func (r yearResolver) Resolve(repoPath string, nowYear int) (string, error) {
switch r.spec.Kind {
case model.YearCurrent:
return fmt.Sprintf("%d", nowYear), nil
case model.YearExplicit:
return fmt.Sprintf("%d", r.spec.Start), nil
case model.YearRange:
return formatRange(r.spec.Start, r.spec.End), nil
case model.YearGit:
first, err := gitutil.FirstCommitYear(repoPath)
if err != nil {
return "", fmt.Errorf("render: resolve git year: %w", err)
}
return formatRange(first, nowYear), nil
default:
return "", errNotImplemented
}
}
// formatRange renders a start-end year range, collapsing equal or inverted bounds
// to a single year so we never emit "2026-2026" or a backwards range.
func formatRange(start, end int) string {
if end <= start {
return fmt.Sprintf("%d", start)
}
return fmt.Sprintf("%d-%d", start, end)
}