diff --git a/lib/src/main/java/com/diffplug/spotless/generic/LicenseHeaderStep.java b/lib/src/main/java/com/diffplug/spotless/generic/LicenseHeaderStep.java index 91ef59a21b..70d524641c 100644 --- a/lib/src/main/java/com/diffplug/spotless/generic/LicenseHeaderStep.java +++ b/lib/src/main/java/com/diffplug/spotless/generic/LicenseHeaderStep.java @@ -48,6 +48,7 @@ /** 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) "; + public static final String DEFAULT_YEAR_STR_FORMAT = "%s"; private static final Logger LOGGER = LoggerFactory.getLogger(LicenseHeaderStep.class); public enum YearMode { @@ -59,7 +60,7 @@ public static LicenseHeaderStep headerDelimiter(String header, String delimiter) } public static LicenseHeaderStep headerDelimiter(ThrowingEx.Supplier headerLazy, String delimiter) { - return new LicenseHeaderStep(null, null, headerLazy, delimiter, DEFAULT_YEAR_DELIMITER, () -> YearMode.PRESERVE, null); + return new LicenseHeaderStep(null, null, headerLazy, delimiter, DEFAULT_YEAR_DELIMITER, () -> YearMode.PRESERVE, null, null); } final String name; @@ -67,16 +68,18 @@ public static LicenseHeaderStep headerDelimiter(ThrowingEx.Supplier head final ThrowingEx.Supplier headerLazy; final String delimiter; final String yearSeparator; + final String yearStrFmt; 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) { + private LicenseHeaderStep(@Nullable String name, @Nullable String contentPattern, ThrowingEx.Supplier headerLazy, String delimiter, String yearSeparator, Supplier yearMode, @Nullable String skipLinesMatching, @Nullable String yearStrFmt) { 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.yearStrFmt = yearStrFmt; this.skipLinesMatching = sanitizePattern(skipLinesMatching); } @@ -85,11 +88,11 @@ public String getName() { } public LicenseHeaderStep withName(String name) { - return new LicenseHeaderStep(name, contentPattern, headerLazy, delimiter, yearSeparator, yearMode, skipLinesMatching); + return new LicenseHeaderStep(name, contentPattern, headerLazy, delimiter, yearSeparator, yearMode, skipLinesMatching, yearStrFmt); } public LicenseHeaderStep withContentPattern(String contentPattern) { - return new LicenseHeaderStep(name, contentPattern, headerLazy, delimiter, yearSeparator, yearMode, skipLinesMatching); + return new LicenseHeaderStep(name, contentPattern, headerLazy, delimiter, yearSeparator, yearMode, skipLinesMatching, yearStrFmt); } public LicenseHeaderStep withHeaderString(String header) { @@ -97,15 +100,15 @@ public LicenseHeaderStep withHeaderString(String header) { } public LicenseHeaderStep withHeaderLazy(ThrowingEx.Supplier headerLazy) { - return new LicenseHeaderStep(name, contentPattern, headerLazy, delimiter, yearSeparator, yearMode, skipLinesMatching); + return new LicenseHeaderStep(name, contentPattern, headerLazy, delimiter, yearSeparator, yearMode, skipLinesMatching, yearStrFmt); } public LicenseHeaderStep withDelimiter(String delimiter) { - return new LicenseHeaderStep(name, contentPattern, headerLazy, delimiter, yearSeparator, yearMode, skipLinesMatching); + return new LicenseHeaderStep(name, contentPattern, headerLazy, delimiter, yearSeparator, yearMode, skipLinesMatching, yearStrFmt); } public LicenseHeaderStep withYearSeparator(String yearSeparator) { - return new LicenseHeaderStep(name, contentPattern, headerLazy, delimiter, yearSeparator, yearMode, skipLinesMatching); + return new LicenseHeaderStep(name, contentPattern, headerLazy, delimiter, yearSeparator, yearMode, skipLinesMatching, yearStrFmt); } public LicenseHeaderStep withYearMode(YearMode yearMode) { @@ -113,11 +116,15 @@ public LicenseHeaderStep withYearMode(YearMode yearMode) { } public LicenseHeaderStep withYearModeLazy(Supplier yearMode) { - return new LicenseHeaderStep(name, contentPattern, headerLazy, delimiter, yearSeparator, yearMode, skipLinesMatching); + return new LicenseHeaderStep(name, contentPattern, headerLazy, delimiter, yearSeparator, yearMode, skipLinesMatching, yearStrFmt); + } + + public LicenseHeaderStep withYearStingFormat(String yearStrFmt) { + return new LicenseHeaderStep(name, contentPattern, headerLazy, delimiter, yearSeparator, yearMode, skipLinesMatching, yearStrFmt); } public LicenseHeaderStep withSkipLinesMatching(@Nullable String skipLinesMatching) { - return new LicenseHeaderStep(name, contentPattern, headerLazy, delimiter, yearSeparator, yearMode, skipLinesMatching); + return new LicenseHeaderStep(name, contentPattern, headerLazy, delimiter, yearSeparator, yearMode, skipLinesMatching, yearStrFmt); } private static class SetLicenseHeaderYearsFromGitHistory implements SerializedFunction { @@ -134,7 +141,7 @@ public FormatterStep build() { if (yearMode.get() == YearMode.SET_FROM_GIT) { formatterStep = FormatterStep.createLazy(name, () -> { boolean updateYear = false; // doesn't matter - return new Runtime(headerLazy.get(), delimiter, yearSeparator, updateYear, skipLinesMatching); + return new Runtime(headerLazy.get(), delimiter, yearSeparator, updateYear, skipLinesMatching, yearStrFmt); }, new SetLicenseHeaderYearsFromGitHistory()); } else { formatterStep = FormatterStep.createLazy(name, () -> { @@ -151,7 +158,7 @@ public FormatterStep build() { default: throw new IllegalStateException(yearMode.toString()); } - return new Runtime(headerLazy.get(), delimiter, yearSeparator, updateYear, skipLinesMatching); + return new Runtime(headerLazy.get(), delimiter, yearSeparator, updateYear, skipLinesMatching, yearStrFmt); }, step -> FormatterFunc.needsFile(step::format)); } if (contentPattern == null) { @@ -214,6 +221,7 @@ private static final class Runtime implements Serializable { private final Pattern delimiterPattern; private final @Nullable Pattern skipLinesMatching; + private final String yearStrFormat; private final String yearSepOrFull; private final @Nullable String yearToday; private final @Nullable String beforeYear; @@ -225,7 +233,7 @@ private static final class Runtime implements Serializable { 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) { + private Runtime(String licenseHeader, String delimiter, String yearSeparator, boolean updateYearWithLatest, @Nullable String skipLinesMatching, @Nullable String yearStrFormat) { if (delimiter.contains("\n")) { throw new IllegalArgumentException("The delimiter must not contain any newlines."); } @@ -236,6 +244,7 @@ private Runtime(String licenseHeader, String delimiter, String yearSeparator, bo } this.delimiterPattern = Pattern.compile('^' + delimiter, Pattern.UNIX_LINES | Pattern.MULTILINE); this.skipLinesMatching = skipLinesMatching == null ? null : Pattern.compile(skipLinesMatching); + this.yearStrFormat = yearStrFormat == null ? DEFAULT_YEAR_STR_FORMAT : yearStrFormat; this.hasFileToken = FILENAME_PATTERN.matcher(licenseHeader).find(); Optional yearToken = getYearToken(licenseHeader); @@ -307,6 +316,14 @@ private String addOrUpdateLicenseHeader(String raw, File file) { return replaceFileName(raw, file); } + private String formatYearStr(final String year) { + if (DEFAULT_YEAR_STR_FORMAT.equals(yearStrFormat)) { + return year; + } else { + return yearStrFormat.formatted(year); + } + } + private String replaceYear(String raw) { Matcher contentMatcher = delimiterPattern.matcher(raw); if (!contentMatcher.find()) { @@ -331,7 +348,7 @@ private String replaceYear(String raw) { 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); + String newYear = formatYearStr(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 @@ -341,7 +358,7 @@ private String replaceYear(String raw) { } return beforeYear + newYear + afterYear + content; } else { - String newYear = calculateYearBySearching(raw.substring(0, contentMatcher.start())); + String newYear = formatYearStr(calculateYearBySearching(raw.substring(0, contentMatcher.start()))); // at worst, we just say that it was made today return beforeYear + newYear + afterYear + content; } @@ -448,7 +465,7 @@ private String setLicenseHeaderYearsFromGitHistory(String raw, File file) throws } else { yearRange = oldYear + yearSepOrFull + newYear; } - return beforeYear + yearRange + afterYear + raw.substring(contentMatcher.start()); + return beforeYear + formatYearStr(yearRange) + afterYear + raw.substring(contentMatcher.start()); } private String replaceFileName(String raw, File file) { diff --git a/plugin-gradle/CHANGES.md b/plugin-gradle/CHANGES.md index 2456e0995a..d04b1fdbbd 100644 --- a/plugin-gradle/CHANGES.md +++ b/plugin-gradle/CHANGES.md @@ -3,6 +3,8 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (starting after version `3.27.0`). ## [Unreleased] +### Added +- Add support for custom string format for license header copyright year via `yearStringFormat()`. ([#2965](https://github.com/diffplug/spotless/pull/2965)) ## [8.7.0] - 2026-06-16 diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/FormatExtension.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/FormatExtension.java index e17f37b0fa..48f3e50eb9 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/FormatExtension.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/FormatExtension.java @@ -614,6 +614,15 @@ public LicenseHeaderConfig yearSeparator(String yearSeparator) { return this; } + /** + * @param yearStrFmt The String format used to format the year part of the license header. + */ + public LicenseHeaderConfig yearStringFormat(String yearStrFmt) { + builder = builder.withYearStingFormat(yearStrFmt); + replaceStep(createStep()); + return this; + } + public LicenseHeaderConfig skipLinesMatching(String skipLinesMatching) { builder = builder.withSkipLinesMatching(skipLinesMatching); replaceStep(createStep()); diff --git a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/LicenseHeaderTest.java b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/LicenseHeaderTest.java index c06958cbd5..5071762b5f 100644 --- a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/LicenseHeaderTest.java +++ b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/LicenseHeaderTest.java @@ -30,7 +30,7 @@ class LicenseHeaderTest extends GradleIntegrationHarness { private static final String TEST_JAVA = "src/main/java/pkg/Test.java"; private static final String CONTENT = "package pkg;\npublic class Test {}"; - private void setLicenseStep(String licenseLine) throws IOException { + private void setLicenseStep(String licenseLine) { setFile("build.gradle").toLines( "plugins {", " id 'com.diffplug.spotless'", @@ -43,45 +43,58 @@ private void setLicenseStep(String licenseLine) throws IOException { "}"); } - private void assertUnchanged(String year) throws IOException { - assertTransform(year, year); + private String formatYearStr(String yearFmt, String year) { + if (yearFmt == null) { + yearFmt = "%s"; + } + return yearFmt.formatted(year); + } + + private void assertUnchanged(String yearFmt, String year) throws IOException { + assertTransform(yearFmt, year, year); } - private void assertTransform(String yearBefore, String yearAfter) throws IOException { + private void assertTransform(String yearFmt, String yearBefore, String yearAfter) throws IOException { + final String yearAfterStr = formatYearStr(yearFmt, yearAfter); + setFile(TEST_JAVA).toContent("/** " + yearBefore + " */\n" + CONTENT); gradleRunner().withArguments("spotlessApply", "--stacktrace").forwardOutput().build(); - assertFile(TEST_JAVA).hasContent("/** " + yearAfter + " */\n" + CONTENT); + assertFile(TEST_JAVA).hasContent("/** " + yearAfterStr + " */\n" + CONTENT); } private void testSuiteUpdateWithLatest(boolean update) throws IOException { + testSuiteUpdateWithLatest(update, null); + } + + private void testSuiteUpdateWithLatest(boolean update, String yearFmt) throws IOException { if (update) { - assertTransform("2003", "2003-" + NOW); - assertTransform(" 2003", "2003-" + NOW); - assertTransform("2003 ", "2003-" + NOW); - assertTransform(" 2003 ", "2003-" + NOW); - - assertTransform("2003-2005", "2003-" + NOW); - assertTransform(" 2003-2005", "2003-" + NOW); - assertTransform("2003-2005 ", "2003-" + NOW); - assertTransform(" 2003-2005 ", "2003-" + NOW); + assertTransform(yearFmt, "2003", "2003-" + NOW); + assertTransform(yearFmt, " 2003", "2003-" + NOW); + assertTransform(yearFmt, "2003 ", "2003-" + NOW); + assertTransform(yearFmt, " 2003 ", "2003-" + NOW); + + assertTransform(yearFmt, "2003-2005", "2003-" + NOW); + assertTransform(yearFmt, " 2003-2005", "2003-" + NOW); + assertTransform(yearFmt, "2003-2005 ", "2003-" + NOW); + assertTransform(yearFmt, " 2003-2005 ", "2003-" + NOW); } else { - assertUnchanged("2003"); - assertTransform(" 2003", "2003"); - assertTransform("2003 ", "2003"); - assertTransform(" 2003 ", "2003"); - - assertUnchanged("2003-2005"); - assertTransform(" 2003-2005", "2003-2005"); - assertTransform("2003-2005 ", "2003-2005"); - assertTransform(" 2003-2005 ", "2003-2005"); + assertUnchanged(yearFmt, "2003"); + assertTransform(yearFmt, " 2003", "2003"); + assertTransform(yearFmt, "2003 ", "2003"); + assertTransform(yearFmt, " 2003 ", "2003"); + + assertUnchanged(yearFmt, "2003-2005"); + assertTransform(yearFmt, " 2003-2005", "2003-2005"); + assertTransform(yearFmt, "2003-2005 ", "2003-2005"); + assertTransform(yearFmt, " 2003-2005 ", "2003-2005"); } - assertUnchanged(NOW); - assertTransform(" " + NOW, NOW); - assertTransform(NOW + " ", NOW); - assertTransform(" " + NOW + " ", NOW); + assertUnchanged(yearFmt, NOW); + assertTransform(yearFmt, " " + NOW, NOW); + assertTransform(yearFmt, NOW + " ", NOW); + assertTransform(yearFmt, " " + NOW + " ", NOW); - assertTransform("", NOW); - assertTransform(" ", NOW); + assertTransform(yearFmt, "", NOW); + assertTransform(yearFmt, " ", NOW); } @Test @@ -96,6 +109,36 @@ void updateYearWithLatestTrue() throws IOException { testSuiteUpdateWithLatest(true); } + @Test + void withYearStringFormat() throws IOException { + // default format + setLicenseStep("licenseHeader('/** $YEAR */').yearStringFormat('%s')"); + testSuiteUpdateWithLatest(false, "%s"); + + // fill with spaces before + setLicenseStep("licenseHeader('/** $YEAR */').yearStringFormat('%11s')"); + testSuiteUpdateWithLatest(false, "%11s"); + + // fill with spaces after + setLicenseStep("licenseHeader('/** $YEAR */').yearStringFormat('%-12s')"); + testSuiteUpdateWithLatest(false, "%-12s"); + } + + @Test + void updateYearWithLatestTrue_withYearStringFormat() throws IOException { + // default format + setLicenseStep("licenseHeader('/** $YEAR */').updateYearWithLatest(true).yearStringFormat('%s')"); + testSuiteUpdateWithLatest(true, "%s"); + + // fill with spaces before + setLicenseStep("licenseHeader('/** $YEAR */').updateYearWithLatest(true).yearStringFormat('%10s')"); + testSuiteUpdateWithLatest(true, "%10s"); + + // fill with spaces after + setLicenseStep("licenseHeader('/** $YEAR */').updateYearWithLatest(true).yearStringFormat('%-15s')"); + testSuiteUpdateWithLatest(true, "%-15s"); + } + @Test void filterByContentPatternTest() throws IOException { setLicenseStep("licenseHeader('/** $YEAR */').onlyIfContentMatches('.+Test.+').updateYearWithLatest(true)"); @@ -131,8 +174,9 @@ void ratchetFromButUpdateFalse() throws Exception { try (Git git = Git.init().setDirectory(rootFolder()).call()) { git.commit().setMessage("First commit").call(); } - Git.init().setDirectory(rootFolder()).call(); - setLicenseStep("licenseHeader('/** $YEAR */').updateYearWithLatest(false)\nratchetFrom 'HEAD'"); - testSuiteUpdateWithLatest(false); + try (Git ignored = Git.init().setDirectory(rootFolder()).call()) { + setLicenseStep("licenseHeader('/** $YEAR */').updateYearWithLatest(false)\nratchetFrom 'HEAD'"); + testSuiteUpdateWithLatest(false); + } } } diff --git a/plugin-maven/CHANGES.md b/plugin-maven/CHANGES.md index 4293a2cb4a..160921f758 100644 --- a/plugin-maven/CHANGES.md +++ b/plugin-maven/CHANGES.md @@ -3,6 +3,8 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (starting after version `1.27.0`). ## [Unreleased] +### Added +- Add support for custom string format for license header copyright year via `yearStringFormat()`. ([#2965](https://github.com/diffplug/spotless/pull/2965)) ## [3.7.0] - 2026-06-16 ### Fixed diff --git a/plugin-maven/src/main/java/com/diffplug/spotless/maven/generic/LicenseHeader.java b/plugin-maven/src/main/java/com/diffplug/spotless/maven/generic/LicenseHeader.java index ff89f55ead..617dfe328a 100644 --- a/plugin-maven/src/main/java/com/diffplug/spotless/maven/generic/LicenseHeader.java +++ b/plugin-maven/src/main/java/com/diffplug/spotless/maven/generic/LicenseHeader.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2023 DiffPlug + * Copyright 2016-2026 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -40,6 +40,9 @@ public class LicenseHeader implements FormatterStepFactory { @Parameter private String skipLinesMatching; + @Parameter + private String yearStrFmt; + @Override public final FormatterStep newFormatterStep(FormatterStepConfig config) { String delimiterString = delimiter != null ? delimiter : config.getLicenseHeaderDelimiter(); @@ -57,6 +60,7 @@ public final FormatterStep newFormatterStep(FormatterStepConfig config) { return LicenseHeaderStep.headerDelimiter(() -> readFileOrContent(config), delimiterString) .withYearMode(yearMode) .withSkipLinesMatching(skipLinesMatching) + .withYearStingFormat(yearStrFmt) .build() .filterByFile(LicenseHeaderStep.unsupportedJvmFilesFilter()); } else {