|
| 1 | +#!/usr/bin/env bash |
| 2 | +# SPDX-License-Identifier: MPL-2.0 |
| 3 | +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) |
| 4 | +# |
| 5 | +# spdx-inject-copyright.sh |
| 6 | +# |
| 7 | +# Idempotent injector for SPDX-License-Identifier + SPDX-FileCopyrightText |
| 8 | +# (Copyright (c) ...) blocks at the top of source / doc files. |
| 9 | +# |
| 10 | +# Comment style is autodetected from file extension; override with --style. |
| 11 | +# |
| 12 | +# Behaviour by file: |
| 13 | +# - If the file already contains the configured Copyright line: skip. |
| 14 | +# - If it has the SPDX-License-Identifier line but not the Copyright |
| 15 | +# line: insert the Copyright line on the line immediately after the |
| 16 | +# SPDX line. |
| 17 | +# - Otherwise: prepend a fresh block (2 lines for line-comment styles, |
| 18 | +# 4 lines for block-comment HTML/Markdown style). |
| 19 | +# |
| 20 | +# Modes: |
| 21 | +# --apply (default) — write changes. |
| 22 | +# --check — exit non-zero if any file would change; do not write. |
| 23 | +# --dry-run — print what would change; do not write. |
| 24 | +# |
| 25 | +# Input: one path per line on stdin, OR --files <list-file>, OR positional |
| 26 | +# args. Files are processed in the order listed. |
| 27 | +# |
| 28 | +# Customisation env vars (or flags): |
| 29 | +# SPDX_LICENSE (default: MPL-2.0) — license identifier. |
| 30 | +# SPDX_COPYRIGHT_HOLDER (required) — copyright holder. |
| 31 | +# Example: "Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>" |
| 32 | +# |
| 33 | +# Exit codes: |
| 34 | +# 0 — success. In --check mode: no file would change. |
| 35 | +# 1 — In --check or --dry-run mode: at least one file would change. |
| 36 | +# In --apply mode: a hard error occurred (missing file, write failure). |
| 37 | +# 2 — invocation / environment error (missing required env var, bad flag). |
| 38 | +# |
| 39 | +# Stats line (always emitted on stderr at end): |
| 40 | +# STATS: processed=N already_has=N added_copyright=N inserted_block=N \ |
| 41 | +# missing=N skipped_unknown_style=N |
| 42 | +# |
| 43 | +# Wired into governance-reusable.yml as the optional `spdx-inject-check` |
| 44 | +# job (calls this script with --check and a file list from caller). |
| 45 | +# |
| 46 | +# Portability: avoids `sed -i` (BSD vs GNU divergence); uses tempfile + mv. |
| 47 | +# Avoids `grep -qF "$pat"` and `printf "$str"` when $pat / $str can |
| 48 | +# begin with `--` (the `-F` / `printf` option parser would mis-classify |
| 49 | +# it as a flag terminator); always uses `grep -F -e "$pat"` and |
| 50 | +# `printf '%s\n' "$str"` form. |
| 51 | + |
| 52 | +set -u |
| 53 | + |
| 54 | +# --- defaults ----------------------------------------------------------------- |
| 55 | + |
| 56 | +LICENSE="${SPDX_LICENSE:-MPL-2.0}" |
| 57 | +HOLDER="${SPDX_COPYRIGHT_HOLDER:-}" |
| 58 | +MODE="apply" |
| 59 | +STYLE="" |
| 60 | +FILES_LIST="" |
| 61 | +declare -a ARG_FILES=() |
| 62 | + |
| 63 | +# --- argument parsing --------------------------------------------------------- |
| 64 | + |
| 65 | +usage() { |
| 66 | + cat >&2 <<'EOF' |
| 67 | +Usage: spdx-inject-copyright.sh [OPTIONS] [FILE...] |
| 68 | +
|
| 69 | + --apply (default) write changes |
| 70 | + --check exit 1 if any file would change; do not write |
| 71 | + --dry-run print what would change; do not write |
| 72 | + --style STYLE force comment style (html|slash|dash|hash|semi) |
| 73 | + overrides extension autodetection |
| 74 | + --license ID license identifier (default: MPL-2.0) |
| 75 | + can also use SPDX_LICENSE env var |
| 76 | + --holder NAME copyright holder (required if SPDX_COPYRIGHT_HOLDER unset) |
| 77 | + can also use SPDX_COPYRIGHT_HOLDER env var |
| 78 | + --files LIST_FILE read paths from LIST_FILE (one per line) |
| 79 | + reads stdin if LIST_FILE is "-" |
| 80 | + --help this message |
| 81 | +
|
| 82 | +Comment style autodetection: |
| 83 | + html .md .html .htm .xml .svg .vue .astro |
| 84 | + slash .rs .c .h .cpp .hpp .cc .hh .zig .js .ts .tsx .jsx .go |
| 85 | + .swift .kt .scala .java .cs .dart .v .sv .adoc .asciidoc |
| 86 | + .css .scss .sass .less |
| 87 | + dash .idr .hs .lhs .sql .lean .elm .purs .ada .vhdl |
| 88 | + hash .ex .exs .py .rb .pl .pm .sh .bash .zsh .fish .yml .yaml |
| 89 | + .toml .r .tcl .jl .nix .dockerfile .makefile .mk |
| 90 | + (also: filenames Makefile, Dockerfile, .gitignore, .envrc) |
| 91 | + semi .lisp .scm .ss .el .clj .cljs .cljc .rkt |
| 92 | +EOF |
| 93 | +} |
| 94 | + |
| 95 | +while [[ $# -gt 0 ]]; do |
| 96 | + case "$1" in |
| 97 | + --apply) MODE="apply"; shift ;; |
| 98 | + --check) MODE="check"; shift ;; |
| 99 | + --dry-run) MODE="dry-run"; shift ;; |
| 100 | + --style) STYLE="$2"; shift 2 ;; |
| 101 | + --license) LICENSE="$2"; shift 2 ;; |
| 102 | + --holder) HOLDER="$2"; shift 2 ;; |
| 103 | + --files) FILES_LIST="$2"; shift 2 ;; |
| 104 | + --help|-h) usage; exit 0 ;; |
| 105 | + --) shift; while [[ $# -gt 0 ]]; do ARG_FILES+=("$1"); shift; done ;; |
| 106 | + -*) echo "ERROR: unknown flag: $1" >&2; usage; exit 2 ;; |
| 107 | + *) ARG_FILES+=("$1"); shift ;; |
| 108 | + esac |
| 109 | +done |
| 110 | + |
| 111 | +if [[ -z "$HOLDER" ]]; then |
| 112 | + echo "ERROR: no copyright holder set. Use --holder NAME or set SPDX_COPYRIGHT_HOLDER." >&2 |
| 113 | + exit 2 |
| 114 | +fi |
| 115 | + |
| 116 | +SPDX_LINE_TEXT="SPDX-License-Identifier: $LICENSE" |
| 117 | +COPY_LINE_TEXT="Copyright (c) $HOLDER" |
| 118 | + |
| 119 | +# --- comment style helpers ---------------------------------------------------- |
| 120 | + |
| 121 | +# Return the comment style for a file based on its extension. |
| 122 | +# Sets the global STYLE_FOR_FILE on success; returns 0 on success, 1 on unknown. |
| 123 | +detect_style() { |
| 124 | + local file="$1" |
| 125 | + local base ext lower |
| 126 | + base="$(basename -- "$file")" |
| 127 | + lower="$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]')" |
| 128 | + |
| 129 | + # Filename-based first (no extension required). |
| 130 | + case "$lower" in |
| 131 | + makefile|dockerfile|.gitignore|.envrc|.editorconfig) |
| 132 | + STYLE_FOR_FILE="hash"; return 0 ;; |
| 133 | + esac |
| 134 | + |
| 135 | + ext="${lower##*.}" |
| 136 | + case "$ext" in |
| 137 | + md|html|htm|xml|svg|vue|astro) |
| 138 | + STYLE_FOR_FILE="html"; return 0 ;; |
| 139 | + rs|c|h|cpp|hpp|cc|hh|zig|js|ts|tsx|jsx|go|swift|kt|scala|java|cs|dart|v|sv|adoc|asciidoc|css|scss|sass|less) |
| 140 | + STYLE_FOR_FILE="slash"; return 0 ;; |
| 141 | + idr|hs|lhs|sql|lean|elm|purs|ada|vhdl) |
| 142 | + STYLE_FOR_FILE="dash"; return 0 ;; |
| 143 | + ex|exs|py|rb|pl|pm|sh|bash|zsh|fish|yml|yaml|toml|r|tcl|jl|nix) |
| 144 | + STYLE_FOR_FILE="hash"; return 0 ;; |
| 145 | + lisp|scm|ss|el|clj|cljs|cljc|rkt) |
| 146 | + STYLE_FOR_FILE="semi"; return 0 ;; |
| 147 | + esac |
| 148 | + STYLE_FOR_FILE="" |
| 149 | + return 1 |
| 150 | +} |
| 151 | + |
| 152 | +# Compute the SPDX line and Copyright line as they would appear in the file |
| 153 | +# (prefixed by the comment marker for line-comment styles, raw for html |
| 154 | +# block style which has its own surrounding markers). |
| 155 | +emit_style_lines() { |
| 156 | + local s="$1" |
| 157 | + case "$s" in |
| 158 | + html) |
| 159 | + BLOCK_OPEN="<!--" |
| 160 | + BLOCK_CLOSE="-->" |
| 161 | + SPDX_FORMATTED="$SPDX_LINE_TEXT" |
| 162 | + COPY_FORMATTED="$COPY_LINE_TEXT" |
| 163 | + ;; |
| 164 | + slash) |
| 165 | + BLOCK_OPEN="" |
| 166 | + BLOCK_CLOSE="" |
| 167 | + SPDX_FORMATTED="// $SPDX_LINE_TEXT" |
| 168 | + COPY_FORMATTED="// $COPY_LINE_TEXT" |
| 169 | + ;; |
| 170 | + dash) |
| 171 | + BLOCK_OPEN="" |
| 172 | + BLOCK_CLOSE="" |
| 173 | + SPDX_FORMATTED="-- $SPDX_LINE_TEXT" |
| 174 | + COPY_FORMATTED="-- $COPY_LINE_TEXT" |
| 175 | + ;; |
| 176 | + hash) |
| 177 | + BLOCK_OPEN="" |
| 178 | + BLOCK_CLOSE="" |
| 179 | + SPDX_FORMATTED="# $SPDX_LINE_TEXT" |
| 180 | + COPY_FORMATTED="# $COPY_LINE_TEXT" |
| 181 | + ;; |
| 182 | + semi) |
| 183 | + BLOCK_OPEN="" |
| 184 | + BLOCK_CLOSE="" |
| 185 | + SPDX_FORMATTED=";; $SPDX_LINE_TEXT" |
| 186 | + COPY_FORMATTED=";; $COPY_LINE_TEXT" |
| 187 | + ;; |
| 188 | + *) return 1 ;; |
| 189 | + esac |
| 190 | + return 0 |
| 191 | +} |
| 192 | + |
| 193 | +# Insert COPY_FORMATTED on the line after the first occurrence of SPDX_LINE_TEXT. |
| 194 | +# Portable: writes to a tempfile, then mv over the original. |
| 195 | +insert_copyright_after_spdx() { |
| 196 | + local file="$1" |
| 197 | + local tmp |
| 198 | + tmp="$(mktemp)" || return 1 |
| 199 | + local line_num |
| 200 | + line_num="$(grep -nF -e "$SPDX_LINE_TEXT" "$file" | head -1 | cut -d: -f1)" |
| 201 | + if [[ -z "$line_num" ]]; then |
| 202 | + rm -f "$tmp" |
| 203 | + return 1 |
| 204 | + fi |
| 205 | + awk -v ln="$line_num" -v new="$COPY_FORMATTED" \ |
| 206 | + 'NR==ln { print; print new; next } { print }' \ |
| 207 | + "$file" > "$tmp" && mv "$tmp" "$file" |
| 208 | +} |
| 209 | + |
| 210 | +# Prepend a fresh block at the top of the file. |
| 211 | +prepend_block() { |
| 212 | + local file="$1" |
| 213 | + local tmp |
| 214 | + tmp="$(mktemp)" || return 1 |
| 215 | + { |
| 216 | + if [[ -n "$BLOCK_OPEN" ]]; then |
| 217 | + printf '%s\n' "$BLOCK_OPEN" |
| 218 | + printf '%s\n' "$SPDX_FORMATTED" |
| 219 | + printf '%s\n' "$COPY_FORMATTED" |
| 220 | + printf '%s\n' "$BLOCK_CLOSE" |
| 221 | + else |
| 222 | + printf '%s\n' "$SPDX_FORMATTED" |
| 223 | + printf '%s\n' "$COPY_FORMATTED" |
| 224 | + fi |
| 225 | + cat -- "$file" |
| 226 | + } > "$tmp" && mv "$tmp" "$file" |
| 227 | +} |
| 228 | + |
| 229 | +# --- counters ---------------------------------------------------------------- |
| 230 | + |
| 231 | +processed=0 |
| 232 | +already_has=0 |
| 233 | +added_copyright=0 |
| 234 | +inserted_block=0 |
| 235 | +missing=0 |
| 236 | +skipped_unknown_style=0 |
| 237 | +would_change=0 |
| 238 | + |
| 239 | +# --- file iteration ---------------------------------------------------------- |
| 240 | + |
| 241 | +process_file() { |
| 242 | + local file="$1" |
| 243 | + if [[ ! -f "$file" ]]; then |
| 244 | + missing=$((missing + 1)) |
| 245 | + return 0 |
| 246 | + fi |
| 247 | + processed=$((processed + 1)) |
| 248 | + |
| 249 | + # Determine style. |
| 250 | + local s |
| 251 | + if [[ -n "$STYLE" ]]; then |
| 252 | + s="$STYLE" |
| 253 | + else |
| 254 | + if detect_style "$file"; then |
| 255 | + s="$STYLE_FOR_FILE" |
| 256 | + else |
| 257 | + skipped_unknown_style=$((skipped_unknown_style + 1)) |
| 258 | + [[ "$MODE" == "dry-run" ]] && echo "SKIP (unknown style): $file" |
| 259 | + return 0 |
| 260 | + fi |
| 261 | + fi |
| 262 | + emit_style_lines "$s" || return 1 |
| 263 | + |
| 264 | + if grep -qF -e "$COPY_LINE_TEXT" -- "$file" 2>/dev/null; then |
| 265 | + already_has=$((already_has + 1)) |
| 266 | + [[ "$MODE" == "dry-run" ]] && echo "SKIP (already has copyright): $file" |
| 267 | + return 0 |
| 268 | + fi |
| 269 | + |
| 270 | + if grep -qF -e "$SPDX_LINE_TEXT" -- "$file" 2>/dev/null; then |
| 271 | + would_change=$((would_change + 1)) |
| 272 | + case "$MODE" in |
| 273 | + check) |
| 274 | + echo "WOULD-INSERT-COPYRIGHT: $file" |
| 275 | + ;; |
| 276 | + dry-run) |
| 277 | + echo "WOULD-INSERT-COPYRIGHT: $file" |
| 278 | + ;; |
| 279 | + apply) |
| 280 | + if ! insert_copyright_after_spdx "$file"; then |
| 281 | + echo "ERROR: insertion failed for $file" >&2 |
| 282 | + return 1 |
| 283 | + fi |
| 284 | + added_copyright=$((added_copyright + 1)) |
| 285 | + ;; |
| 286 | + esac |
| 287 | + else |
| 288 | + would_change=$((would_change + 1)) |
| 289 | + case "$MODE" in |
| 290 | + check) |
| 291 | + echo "WOULD-PREPEND-BLOCK: $file" |
| 292 | + ;; |
| 293 | + dry-run) |
| 294 | + echo "WOULD-PREPEND-BLOCK: $file" |
| 295 | + ;; |
| 296 | + apply) |
| 297 | + if ! prepend_block "$file"; then |
| 298 | + echo "ERROR: prepend failed for $file" >&2 |
| 299 | + return 1 |
| 300 | + fi |
| 301 | + inserted_block=$((inserted_block + 1)) |
| 302 | + ;; |
| 303 | + esac |
| 304 | + fi |
| 305 | +} |
| 306 | + |
| 307 | +# Read files from sources, in priority order: positional > --files > stdin. |
| 308 | +if [[ ${#ARG_FILES[@]} -gt 0 ]]; then |
| 309 | + for f in "${ARG_FILES[@]}"; do |
| 310 | + process_file "$f" || exit 1 |
| 311 | + done |
| 312 | +elif [[ -n "$FILES_LIST" ]]; then |
| 313 | + if [[ "$FILES_LIST" == "-" ]]; then |
| 314 | + while IFS= read -r f; do |
| 315 | + [[ -z "$f" ]] && continue |
| 316 | + process_file "$f" || exit 1 |
| 317 | + done |
| 318 | + else |
| 319 | + while IFS= read -r f; do |
| 320 | + [[ -z "$f" ]] && continue |
| 321 | + process_file "$f" || exit 1 |
| 322 | + done < "$FILES_LIST" |
| 323 | + fi |
| 324 | +else |
| 325 | + while IFS= read -r f; do |
| 326 | + [[ -z "$f" ]] && continue |
| 327 | + process_file "$f" || exit 1 |
| 328 | + done |
| 329 | +fi |
| 330 | + |
| 331 | +# --- stats ------------------------------------------------------------------ |
| 332 | + |
| 333 | +printf 'STATS: processed=%d already_has=%d added_copyright=%d inserted_block=%d missing=%d skipped_unknown_style=%d\n' \ |
| 334 | + "$processed" "$already_has" "$added_copyright" "$inserted_block" "$missing" "$skipped_unknown_style" >&2 |
| 335 | + |
| 336 | +# --- exit code -------------------------------------------------------------- |
| 337 | + |
| 338 | +case "$MODE" in |
| 339 | + check|dry-run) |
| 340 | + if [[ $would_change -gt 0 ]]; then |
| 341 | + exit 1 |
| 342 | + fi |
| 343 | + exit 0 |
| 344 | + ;; |
| 345 | + apply) |
| 346 | + exit 0 |
| 347 | + ;; |
| 348 | +esac |
0 commit comments