/* * Copyright 2016-2023 DiffPlug * * Licensed 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. */ package com.diffplug.spotless.generic; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.Serializable; import java.time.YearMonth; import java.util.Arrays; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.function.Supplier; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.annotation.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.diffplug.spotless.FileSignature; import com.diffplug.spotless.FormatterFunc; import com.diffplug.spotless.FormatterStep; import com.diffplug.spotless.LineEnding; import com.diffplug.spotless.OnMatch; import com.diffplug.spotless.SerializableFileFilter; import com.diffplug.spotless.ThrowingEx; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; /** Prefixes a license header before the package statement. */ public final class LicenseHeaderStep { public static final String DEFAULT_JAVA_HEADER_DELIMITER = "(package|import|public|class|module) "; private static final Logger LOGGER = LoggerFactory.getLogger(LicenseHeaderStep.class); public enum YearMode { PRESERVE, UPDATE_TO_TODAY, SET_FROM_GIT } public static LicenseHeaderStep headerDelimiter(String header, String delimiter) { return headerDelimiter(() -> header, delimiter); } public static LicenseHeaderStep headerDelimiter(ThrowingEx.Supplier headerLazy, String delimiter) { return new LicenseHeaderStep(null, null, headerLazy, delimiter, DEFAULT_YEAR_DELIMITER, () -> YearMode.PRESERVE, null); } final String name; final @Nullable String contentPattern; final ThrowingEx.Supplier headerLazy; final String delimiter; final String yearSeparator; final Supplier yearMode; final @Nullable String skipLinesMatching; private LicenseHeaderStep(@Nullable String name, @Nullable String contentPattern, ThrowingEx.Supplier headerLazy, String delimiter, String yearSeparator, Supplier yearMode, @Nullable String skipLinesMatching) { this.name = sanitizeName(name); this.contentPattern = sanitizePattern(contentPattern); this.headerLazy = Objects.requireNonNull(headerLazy); this.delimiter = Objects.requireNonNull(delimiter); this.yearSeparator = Objects.requireNonNull(yearSeparator); this.yearMode = Objects.requireNonNull(yearMode); this.skipLinesMatching = sanitizePattern(skipLinesMatching); } public String getName() { return name; } public LicenseHeaderStep withName(String name) { return new LicenseHeaderStep(name, contentPattern, headerLazy, delimiter, yearSeparator, yearMode, skipLinesMatching); } public LicenseHeaderStep withContentPattern(String contentPattern) { return new LicenseHeaderStep(name, contentPattern, headerLazy, delimiter, yearSeparator, yearMode, skipLinesMatching); } public LicenseHeaderStep withHeaderString(String header) { return withHeaderLazy(() -> header); } public LicenseHeaderStep withHeaderLazy(ThrowingEx.Supplier headerLazy) { return new LicenseHeaderStep(name, contentPattern, headerLazy, delimiter, yearSeparator, yearMode, skipLinesMatching); } public LicenseHeaderStep withDelimiter(String delimiter) { return new LicenseHeaderStep(name, contentPattern, headerLazy, delimiter, yearSeparator, yearMode, skipLinesMatching); } public LicenseHeaderStep withYearSeparator(String yearSeparator) { return new LicenseHeaderStep(name, contentPattern, headerLazy, delimiter, yearSeparator, yearMode, skipLinesMatching); } public LicenseHeaderStep withYearMode(YearMode yearMode) { return withYearModeLazy(() -> yearMode); } public LicenseHeaderStep withYearModeLazy(Supplier yearMode) { return new LicenseHeaderStep(name, contentPattern, headerLazy, delimiter, yearSeparator, yearMode, skipLinesMatching); } public LicenseHeaderStep withSkipLinesMatching(@Nullable String skipLinesMatching) { return new LicenseHeaderStep(name, contentPattern, headerLazy, delimiter, yearSeparator, yearMode, skipLinesMatching); } public FormatterStep build() { FormatterStep formatterStep = null; if (yearMode.get() == YearMode.SET_FROM_GIT) { formatterStep = FormatterStep.createNeverUpToDateLazy(name, () -> { boolean updateYear = false; // doesn't matter Runtime runtime = new Runtime(headerLazy.get(), delimiter, yearSeparator, updateYear, skipLinesMatching); return FormatterFunc.needsFile(runtime::setLicenseHeaderYearsFromGitHistory); }); } else { formatterStep = FormatterStep.createLazy(name, () -> { // by default, we should update the year if the user is using ratchetFrom boolean updateYear; switch (yearMode.get()) { case PRESERVE: updateYear = false; break; case UPDATE_TO_TODAY: updateYear = true; break; case SET_FROM_GIT: default: throw new IllegalStateException(yearMode.toString()); } return new Runtime(headerLazy.get(), delimiter, yearSeparator, updateYear, skipLinesMatching); }, step -> FormatterFunc.needsFile(step::format)); } if (contentPattern == null) { return formatterStep; } return formatterStep.filterByContent(OnMatch.INCLUDE, contentPattern); } private String sanitizeName(@Nullable String name) { if (name == null) { return DEFAULT_NAME_PREFIX; } name = name.trim(); if (Objects.equals(DEFAULT_NAME_PREFIX, name) || name.startsWith(DEFAULT_NAME_PREFIX)) { return name; } return DEFAULT_NAME_PREFIX + "-" + name; } @Nullable private String sanitizePattern(@Nullable String pattern) { if (pattern == null) { return pattern; } pattern = pattern.trim(); if (pattern.isEmpty()) { return null; } return pattern; } private static final String DEFAULT_NAME_PREFIX = LicenseHeaderStep.class.getName(); private static final String DEFAULT_YEAR_DELIMITER = "-"; private static final List YEAR_TOKENS = Arrays.asList("$YEAR", "$today.year"); private static final SerializableFileFilter UNSUPPORTED_JVM_FILES_FILTER = SerializableFileFilter.skipFilesNamed( "package-info.java", "package-info.groovy", "module-info.java"); public static String defaultYearDelimiter() { return DEFAULT_YEAR_DELIMITER; } public static SerializableFileFilter unsupportedJvmFilesFilter() { return UNSUPPORTED_JVM_FILES_FILTER; } public static final String spotlessSetLicenseHeaderYearsFromGitHistory = "spotlessSetLicenseHeaderYearsFromGitHistory"; public static final String FLAG_SET_LICENSE_HEADER_YEARS_FROM_GIT_HISTORY() { return spotlessSetLicenseHeaderYearsFromGitHistory; } private static class Runtime implements Serializable { private static final long serialVersionUID = 1475199492829130965L; private final Pattern delimiterPattern; private final @Nullable Pattern skipLinesMatching; private final String yearSepOrFull; private final @Nullable String yearToday; private final @Nullable String beforeYear; private final @Nullable String afterYear; private final boolean updateYearWithLatest; private final boolean licenseHeaderWithRange; private final boolean hasFileToken; private static final Pattern FILENAME_PATTERN = Pattern.compile("\\$FILE"); /** The license that we'd like enforced. */ private Runtime(String licenseHeader, String delimiter, String yearSeparator, boolean updateYearWithLatest, @Nullable String skipLinesMatching) { if (delimiter.contains("\n")) { throw new IllegalArgumentException("The delimiter must not contain any newlines."); } // sanitize the input license licenseHeader = LineEnding.toUnix(licenseHeader); if (!licenseHeader.isEmpty() && !licenseHeader.endsWith("\n")) { licenseHeader = licenseHeader + "\n"; } this.delimiterPattern = Pattern.compile('^' + delimiter, Pattern.UNIX_LINES | Pattern.MULTILINE); this.skipLinesMatching = skipLinesMatching == null ? null : Pattern.compile(skipLinesMatching); this.hasFileToken = FILENAME_PATTERN.matcher(licenseHeader).find(); Optional yearToken = getYearToken(licenseHeader); if (yearToken.isPresent()) { this.yearToday = String.valueOf(YearMonth.now().getYear()); int yearTokenIndex = licenseHeader.indexOf(yearToken.get()); this.beforeYear = licenseHeader.substring(0, yearTokenIndex); this.afterYear = licenseHeader.substring(yearTokenIndex + yearToken.get().length()); this.yearSepOrFull = yearSeparator; this.updateYearWithLatest = updateYearWithLatest; boolean hasHeaderWithRange = false; int yearPlusSep = 4 + yearSeparator.length(); if (beforeYear.endsWith(yearSeparator) && yearTokenIndex > yearPlusSep) { // year from in range String yearFrom = licenseHeader.substring(yearTokenIndex - yearPlusSep, yearTokenIndex).substring(0, 4); hasHeaderWithRange = YYYY.matcher(yearFrom).matches(); } this.licenseHeaderWithRange = hasHeaderWithRange; } else { this.yearToday = null; this.beforeYear = null; this.afterYear = null; this.yearSepOrFull = licenseHeader; this.updateYearWithLatest = false; this.licenseHeaderWithRange = false; } } /** * Get the first place holder token being used in the * license header for specifying the year * * @param licenseHeader String representation of the license header * @return Matching value from YEAR_TOKENS or null if none exist */ private static Optional getYearToken(String licenseHeader) { return YEAR_TOKENS.stream().filter(licenseHeader::contains).findFirst(); } /** Formats the given string. */ private String format(String raw, File file) { if (skipLinesMatching == null) { return addOrUpdateLicenseHeader(raw, file); } else { String[] lines = raw.split("\n"); StringBuilder skippedLinesBuilder = new StringBuilder(); StringBuilder remainingLinesBuilder = new StringBuilder(); boolean lastMatched = true; for (String line : lines) { if (lastMatched) { Matcher matcher = skipLinesMatching.matcher(line); if (matcher.find()) { skippedLinesBuilder.append(line).append('\n'); } else { remainingLinesBuilder.append(line).append('\n'); lastMatched = false; } } else { remainingLinesBuilder.append(line).append('\n'); } } return skippedLinesBuilder + addOrUpdateLicenseHeader(remainingLinesBuilder.toString(), file); } } private String addOrUpdateLicenseHeader(String raw, File file) { raw = replaceYear(raw); raw = replaceFileName(raw, file); return raw; } private String replaceYear(String raw) { Matcher contentMatcher = delimiterPattern.matcher(raw); if (!contentMatcher.find()) { throw new IllegalArgumentException("Unable to find delimiter regex " + delimiterPattern); } else { String content = raw.substring(contentMatcher.start()); if (yearToday == null) { // the no year case is easy if (contentMatcher.start() == yearSepOrFull.length() && raw.startsWith(yearSepOrFull)) { // if no change is required, return the raw string without // creating any other new strings for maximum performance return raw; } else { // otherwise we'll have to add the header return yearSepOrFull + content; } } else { // the yes year case is a bit harder int beforeYearIdx = raw.indexOf(beforeYear); int afterYearIdx = raw.indexOf(afterYear, beforeYearIdx + beforeYear.length() + 1); if (beforeYearIdx >= 0 && afterYearIdx >= 0 && afterYearIdx + afterYear.length() <= contentMatcher.start()) { // and also ends with exactly the right header, so it's easy to parse the existing year String existingYear = raw.substring(beforeYearIdx + beforeYear.length(), afterYearIdx); String newYear = calculateYearExact(existingYear); if (existingYear.equals(newYear)) { // fastpath where we don't need to make any changes at all boolean noPadding = beforeYearIdx == 0 && afterYearIdx + afterYear.length() == contentMatcher.start(); // allows fastpath return raw if (noPadding) { return raw; } } return beforeYear + newYear + afterYear + content; } else { String newYear = calculateYearBySearching(raw.substring(0, contentMatcher.start())); // at worst, we just say that it was made today return beforeYear + newYear + afterYear + content; } } } } private static final Pattern YYYY = Pattern.compile("[0-9]{4}"); /** Calculates the year to inject. */ private String calculateYearExact(String parsedYear) { if (parsedYear.equals(yearToday)) { return parsedYear; } else if (YYYY.matcher(parsedYear).matches()) { if (updateYearWithLatest) { if (licenseHeaderWithRange) { return yearToday; } else { return parsedYear + yearSepOrFull + yearToday; } } else { // it's already good as a single year return parsedYear; } } else { return calculateYearBySearching(parsedYear); } } /** Searches the given string for YYYY, and uses that to determine the year range. */ private String calculateYearBySearching(String content) { Matcher yearMatcher = YYYY.matcher(content); if (yearMatcher.find()) { String firstYear = yearMatcher.group(); String secondYear = null; if (updateYearWithLatest) { secondYear = firstYear.equals(yearToday) ? null : yearToday; } else { String contentWithSecondYear = content.substring(yearMatcher.end() + 1); int endOfLine = contentWithSecondYear.indexOf('\n'); if (endOfLine != -1) { contentWithSecondYear = contentWithSecondYear.substring(0, endOfLine); } Matcher secondYearMatcher = YYYY.matcher(contentWithSecondYear); if (secondYearMatcher.find()) { secondYear = secondYearMatcher.group(); } } if (secondYear == null) { return firstYear; } else { if (licenseHeaderWithRange) { return secondYear; } else { return firstYear + yearSepOrFull + secondYear; } } } else { LOGGER.warn("Can't parse copyright year '{}', defaulting to {}", content, yearToday); // couldn't recognize the year format return yearToday; } } /** Sets copyright years on the given file by finding the oldest and most recent commits throughout git history. */ private String setLicenseHeaderYearsFromGitHistory(String raw, File file) throws IOException { if (yearToday == null) { return raw; } Matcher contentMatcher = delimiterPattern.matcher(raw); if (!contentMatcher.find()) { throw new IllegalArgumentException("Unable to find delimiter regex " + delimiterPattern); } String oldYear; try { oldYear = parseYear("git log --follow --find-renames=40% --diff-filter=A", file); } catch (IllegalArgumentException e) { // Ideally, git log would always find the commit where it was added. // For some reason, that is sometimes not possible - in that case, // we'll settle for just the most recent, even if it was just a modification. oldYear = parseYear("git log --follow --find-renames=40% --reverse", file); } String newYear = parseYear("git log --max-count=1", file); String yearRange; if (oldYear.equals(newYear)) { yearRange = oldYear; } else { yearRange = oldYear + yearSepOrFull + newYear; } return beforeYear + yearRange + afterYear + raw.substring(contentMatcher.start()); } private String replaceFileName(String raw, File file) { if (!hasFileToken) { return raw; } Matcher contentMatcher = delimiterPattern.matcher(raw); if (!contentMatcher.find()) { throw new IllegalArgumentException("Unable to find delimiter regex " + delimiterPattern); } String header = raw.substring(0, contentMatcher.start()); String content = raw.substring(contentMatcher.start()); return FILENAME_PATTERN.matcher(header).replaceAll(file.getName()) + content; } private static String parseYear(String cmd, File file) throws IOException { String fullCmd = cmd + " -- " + file.getAbsolutePath(); ProcessBuilder builder = new ProcessBuilder().directory(file.getParentFile()); if (FileSignature.machineIsWin()) { builder.command("cmd", "/c", fullCmd); } else { builder.command("bash", "-c", fullCmd); } Process process = builder.start(); String output = drain(process.getInputStream()); String error = drain(process.getErrorStream()); if (!error.isEmpty()) { throw new IllegalArgumentException("Error for command '" + fullCmd + "':\n" + error); } Matcher matcher = FIND_YEAR.matcher(output); if (matcher.find()) { return matcher.group(1); } else { throw new IllegalArgumentException("Unable to parse date from command '" + fullCmd + "':\n" + output); } } private static final Pattern FIND_YEAR = Pattern.compile("Date: .* ([0-9]{4}) "); @SuppressFBWarnings("DM_DEFAULT_ENCODING") private static String drain(InputStream stream) throws IOException { ByteArrayOutputStream output = new ByteArrayOutputStream(); byte[] buf = new byte[1024]; int numRead; while ((numRead = stream.read(buf)) != -1) { output.write(buf, 0, numRead); } return new String(output.toByteArray()); } } }