blob: ce8b7acccff74f870c16427a2ed574b3a780d89a [file]
#!/usr/bin/env python3
# -------------------------------------------------------------
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
# -------------------------------------------------------------
#
# Line-scoped Java formatting against dev/CodeStyle_eclipse.xml.
#
# The Eclipse formatter only works on whole files, and the existing tree is not
# fully formatter-clean, so formatting a whole edited file would flag lines the
# PR never touched. This script therefore formats each changed file but keeps
# only the formatting changes that fall on the lines the PR edited (like
# clang-format-diff): it diffs the original against the fully-formatted version
# and restricts the result to the changed line ranges.
#
# --check (default): print the changed-line formatting fixes and exit 1 if any.
# --fix : apply only the changed-line formatting fixes in place.
#
# Usage: dev/format_changed.py [--check|--fix] [base-ref]
#
import difflib
import fnmatch
import os
import re
import subprocess
import sys
FMT_VERSION = "2.24.1"
CONFIG = "dev/CodeStyle_eclipse.xml"
EXCLUDE_FILE = "dev/format-exclude.txt"
SRC_PREFIX = r"src/(main|test)/java/"
SRC_RE = re.compile(r"^" + SRC_PREFIX + r".+\.java$")
# --- small process / IO helpers ------------------------------------------------
def git(*args, check=True):
# core.quotePath=false so non-ASCII paths are emitted verbatim (not \NNN
# escaped), otherwise SRC_RE would silently skip them and bypass the check.
proc = subprocess.run(["git", "-c", "core.quotePath=false", *args],
capture_output=True, text=True)
if check and proc.returncode != 0:
sys.exit(f"ERROR: `git {' '.join(args)}` failed:\n{proc.stderr.strip()}")
return proc.stdout
def read_text(path):
# newline="" keeps line endings byte-exact so a check-mode restore (or a
# fix-mode non-flagged file) round-trips without CRLF->LF rewrites.
with open(path, encoding="utf-8", newline="") as fh:
return fh.read()
def write_text(path, text):
with open(path, "w", encoding="utf-8", newline="") as fh:
fh.write(text)
def strip_src_prefix(path):
return re.sub(r"^" + SRC_PREFIX, "", path)
# --- exemption list ------------------------------------------------------------
def load_excludes():
# glob patterns of files exempt from the style check, one per line
patterns = []
if os.path.exists(EXCLUDE_FILE):
with open(EXCLUDE_FILE, encoding="utf-8") as fh:
for line in fh:
s = line.strip()
if s and not s.startswith("#"):
patterns.append(s)
return patterns
def is_excluded(path, patterns):
base = os.path.basename(path)
return any(fnmatch.fnmatch(path, p) or fnmatch.fnmatch(base, p) for p in patterns)
# --- git ref / file discovery --------------------------------------------------
def ref_exists(ref):
return subprocess.run(["git", "rev-parse", "--verify", "--quiet", ref + "^{commit}"],
capture_output=True).returncode == 0
def resolve_base(explicit):
if explicit:
if not ref_exists(explicit):
sys.exit(f"Base ref not found: {explicit}")
return explicit
for ref in ("upstream/main", "origin/main", "main"):
if ref_exists(ref):
return ref
sys.exit("Could not determine a base ref; pass one explicitly.")
def merge_base(base):
mb = git("merge-base", base, "HEAD", check=False).strip()
return mb or base
def base_has(mergebase, path):
return subprocess.run(["git", "cat-file", "-e", f"{mergebase}:{path}"],
capture_output=True).returncode == 0
def discover_files(mergebase):
tracked = [f for f in git("diff", "--name-only", "--diff-filter=ACMR",
mergebase, "--").splitlines() if SRC_RE.match(f)]
untracked = [f for f in git("ls-files", "--others", "--exclude-standard").splitlines()
if SRC_RE.match(f)]
seen = set(tracked)
files = tracked + [f for f in untracked if f not in seen]
patterns = load_excludes()
skipped = [f for f in files if is_excluded(f, patterns)]
files = [f for f in files if not is_excluded(f, patterns)]
return files, skipped, set(untracked)
# --- changed-line ranges (pure parsing, unit-tested) ---------------------------
def _hunk_range(header):
# parse the new-side (+) range of a `@@ -a,b +c,d @@` unified-diff header;
# returns a 1-based inclusive (start, end), or None for pure deletions / non-headers
m = re.match(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@", header)
if not m:
return None
start = int(m.group(1))
count = int(m.group(2)) if m.group(2) is not None else 1
return (start, start + count - 1) if count > 0 else None
def parse_hunks(diff_text):
# all new-side ranges in a single-file unified diff (used by tests and below)
ranges = []
for line in diff_text.splitlines():
r = _hunk_range(line)
if r is not None:
ranges.append(r)
return ranges
def changed_ranges(mergebase, files):
# one batched `git diff` for all files, split per file on the `+++ b/` header
ranges = {f: [] for f in files}
if not files:
return ranges
out = git("diff", "-U0", mergebase, "--", *files)
current = None
for line in out.splitlines():
if line.startswith("+++ b/"):
current = line[len("+++ b/"):]
elif current is not None:
r = _hunk_range(line)
if r is not None and current in ranges:
ranges[current].append(r)
return ranges
def overlaps(i1, i2, ranges):
# original-side region [i1, i2) (0-based half-open) vs 1-based inclusive ranges
lo, hi = i1 + 1, i2 # convert to 1-based inclusive; insert (i1==i2) -> lo>hi
for (s, e) in ranges:
if i1 == i2: # pure insertion between original lines i1 and i1+1
if s - 1 <= i1 <= e:
return True
elif not (hi < s or lo > e):
return True
return False
def line_scoped_result(original_text, formatted_text, ranges):
# reconstruct a file that keeps original content everywhere except on the
# formatting hunks that intersect the PR-edited ranges; returns the new text
# plus the kept hunks (for reporting). Pure function -- no IO.
a = original_text.splitlines(keepends=True)
b = formatted_text.splitlines(keepends=True)
sm = difflib.SequenceMatcher(None, a, b, autojunk=False)
result = []
kept = []
for tag, i1, i2, j1, j2 in sm.get_opcodes():
if tag == "equal":
result.extend(a[i1:i2])
elif overlaps(i1, i2, ranges):
result.extend(b[j1:j2])
kept.append((i1, len(a[i1:i2]), len(b[j1:j2]), a[i1:i2], b[j1:j2]))
else:
result.extend(a[i1:i2])
return "".join(result), kept
# --- formatter -----------------------------------------------------------------
def run_formatter(files):
includes = ",".join(strip_src_prefix(f) for f in files)
subprocess.run(["mvn", "-q", "-ntp", "-B",
f"net.revelc.code.formatter:formatter-maven-plugin:{FMT_VERSION}:format",
f"-Dconfigfile={os.getcwd()}/{CONFIG}",
"-Dmaven.compiler.source=17", "-Dmaven.compiler.target=17",
f"-Dformatter.includes={includes}"], check=True)
def print_report(path, kept):
print(f"\n--- a/{path}")
print(f"+++ b/{path}")
for i1, n_old, n_new, olds, news in kept:
print(f"@@ -{i1 + 1},{n_old} +{i1 + 1},{n_new} @@ (changed lines)")
for ln in olds:
print("-" + ln.rstrip("\n"))
for ln in news:
print("+" + ln.rstrip("\n"))
# --- CLI -----------------------------------------------------------------------
def parse_args(argv):
mode = "check"
positionals = []
for a in argv:
if a == "--fix":
mode = "fix"
elif a == "--check":
mode = "check"
elif a.startswith("-"):
sys.exit(f"Unknown option: {a} (usage: format_changed.py [--check|--fix] [base-ref])")
else:
positionals.append(a)
if len(positionals) > 1:
sys.exit(f"Expected at most one base ref, got: {positionals}")
return mode, (positionals[0] if positionals else None)
def compute_ranges(mergebase, files, untracked, originals):
batched = changed_ranges(mergebase, files)
ranges_by_file = {}
for f in files:
r = batched[f]
if not r and (f in untracked or not base_has(mergebase, f)):
# brand-new/untracked file has no base version: treat every line as edited
r = [(1, max(1, len(originals[f].splitlines())))]
ranges_by_file[f] = r
return ranges_by_file
def main():
mode, base_arg = parse_args(sys.argv[1:])
os.chdir(git("rev-parse", "--show-toplevel").strip())
base = resolve_base(base_arg)
mergebase = merge_base(base)
files, skipped, untracked = discover_files(mergebase)
if skipped:
print(f"Skipping style-exempt files ({EXCLUDE_FILE}):")
for f in skipped:
print(f" {f}")
if not files:
print(f"No changed Java source files to check (base: {base}).")
return 0
originals = {f: read_text(f) for f in files}
ranges_by_file = compute_ranges(mergebase, files, untracked, originals)
reports = []
wrote_fix = False
try:
try:
run_formatter(files)
except subprocess.CalledProcessError as e:
sys.exit(f"ERROR: could not run the Eclipse formatter (is `mvn` on PATH?): {e}")
results = {}
for f in files:
result, kept = line_scoped_result(originals[f], read_text(f), ranges_by_file[f])
results[f] = result
if kept:
reports.append((f, kept))
if mode == "fix":
for f in files:
write_text(f, results[f])
wrote_fix = True
finally:
# never leave mvn's whole-file reformat on disk: check mode is read-only,
# and fix mode must restore originals unless it fully wrote the scoped results
if mode == "check" or (mode == "fix" and not wrote_fix):
for f in files:
write_text(f, originals[f])
if reports and mode == "check":
for path, kept in reports:
print_report(path, kept)
print("\nERROR: the changes above are required on lines this PR edited "
"(per dev/CodeStyle_eclipse.xml).")
print("Fix locally with: dev/format-changed.sh")
return 1
if reports and mode == "fix":
print("Applied changed-line formatting. Review and commit the result.")
else:
print("All PR-edited Java lines are correctly formatted.")
return 0
if __name__ == "__main__":
sys.exit(main())