From 73f37d307b5e9f1c0eb1fc9372dfb7c78d5808be Mon Sep 17 00:00:00 2001 From: Maciej Sitarz Date: Tue, 9 Jun 2026 14:35:27 +0200 Subject: [PATCH 01/10] Initial feature implementation --- .../spotless/generic/LicenseHeaderStep.java | 82 ++++++++++++++----- .../gradle/spotless/FormatExtension.java | 9 ++ .../gradle/spotless/LicenseHeaderTest.java | 71 +++++++++++++--- .../spotless/maven/generic/LicenseHeader.java | 6 +- 4 files changed, 137 insertions(+), 31 deletions(-) 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 bcb743083e..b55049dd92 100644 --- a/lib/src/main/java/com/diffplug/spotless/generic/LicenseHeaderStep.java +++ b/lib/src/main/java/com/diffplug/spotless/generic/LicenseHeaderStep.java @@ -59,7 +59,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 +67,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 +87,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 +99,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 +115,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 +140,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 +157,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 +220,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 +232,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 +243,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 ? "%s" : yearStrFormat; this.hasFileToken = FILENAME_PATTERN.matcher(licenseHeader).find(); Optional yearToken = getYearToken(licenseHeader); @@ -303,11 +311,20 @@ private String format(String raw, File file) { } private String addOrUpdateLicenseHeader(String raw, File file) { + System.out.println("YEARFMT DEBUG: addOrUpdateLicenseHeader() start"); + System.out.println("YEARFMT DEBUG: addOrUpdateLicenseHeader() raw: " + raw); + System.out.println("YEARFMT DEBUG: addOrUpdateLicenseHeader() file: " + file.getAbsolutePath()); raw = replaceYear(raw); return replaceFileName(raw, file); } + private String formatYearStr(String year) { + return yearStrFormat.formatted(year); + } + private String replaceYear(String raw) { + System.out.println("YEARFMT DEBUG: replaceYear() start"); + System.out.println("YEARFMT DEBUG: replaceYear() raw: " + raw); Matcher contentMatcher = delimiterPattern.matcher(raw); if (!contentMatcher.find()) { throw new IllegalArgumentException("Unable to find delimiter regex " + delimiterPattern); @@ -318,8 +335,15 @@ private String replaceYear(String raw) { 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 + System.out.println("YEARFMT DEBUG: 0: raw: '" + raw + "'"); return raw; } else { + final String yearStr = formatYearStr(yearSepOrFull); + + System.out.println("YEARFMT DEBUG: 1: yearSepOrFull: '" + yearSepOrFull + "'"); + System.out.println("YEARFMT DEBUG: 1: yearStr: '" + yearStr + "'"); + + // return yearStr + content; // otherwise we'll have to add the header return yearSepOrFull + content; } @@ -339,11 +363,27 @@ private String replaceYear(String raw) { return raw; } } + +// final String yearStr = formatYear(newYear); + System.out.println("YEARFMT DEBUG: 2: beforeYear: '" + beforeYear + "'"); + System.out.println("YEARFMT DEBUG: 2: newYear: '" + newYear + "'"); + System.out.println("YEARFMT DEBUG: 2: afterYear: '" + afterYear + "'"); +// System.out.println("YEARFMT DEBUG: 2: yearStr: '" + yearStr + "'"); + + // return beforeYear + yearStr + afterYear + content; return beforeYear + newYear + afterYear + content; } else { String newYear = calculateYearBySearching(raw.substring(0, contentMatcher.start())); + final String yearStr = formatYearStr(newYear); + + System.out.println("YEARFMT DEBUG: 3: beforeYear: '" + beforeYear + "'"); + System.out.println("YEARFMT DEBUG: 3: newYear: '" + newYear + "'"); + System.out.println("YEARFMT DEBUG: 3: afterYear: '" + afterYear + "'"); + System.out.println("YEARFMT DEBUG: 3: yearStr: '" + yearStr + "'"); + // at worst, we just say that it was made today - return beforeYear + newYear + afterYear + content; + return beforeYear + yearStr + afterYear + content; + // return beforeYear + newYear + afterYear + content; } } } @@ -353,26 +393,30 @@ private String replaceYear(String raw) { /** Calculates the year to inject. */ private String calculateYearExact(String parsedYear) { - if (parsedYear.equals(yearToday)) { - return parsedYear; + System.out.println("YEARFMT DEBUG: calculateYearExact() start"); + System.out.println("YEARFMT DEBUG: calculateYearExact() parsedYear: " + parsedYear); + if (parsedYear.equals(formatYearStr(yearToday))) { + return formatYearStr(parsedYear); } else if (YYYY.matcher(parsedYear).matches()) { if (updateYearWithLatest) { if (licenseHeaderWithRange) { - return yearToday; + return formatYearStr(yearToday); } else { - return parsedYear + yearSepOrFull + yearToday; + return formatYearStr(parsedYear + yearSepOrFull + yearToday); } } else { // it's already good as a single year - return parsedYear; + return formatYearStr(parsedYear); } } else { - return calculateYearBySearching(parsedYear); + return formatYearStr(calculateYearBySearching(parsedYear)); } } /** Searches the given string for YYYY, and uses that to determine the year range. */ private String calculateYearBySearching(String content) { + System.out.println("YEARFMT DEBUG: calculateYearBySearching() start"); + System.out.println("YEARFMT DEBUG: calculateYearBySearching() content: " + content); Matcher yearMatcher = YYYY.matcher(content); if (yearMatcher.find()) { String firstYear = yearMatcher.group(); 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 a8ac2dd9cc..bfd17595ea 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 @@ -1,5 +1,5 @@ /* - * Copyright 2016-2025 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. @@ -43,26 +43,39 @@ 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-2005", "2003-" + NOW); + assertTransform(yearFmt, "2003", "2003-" + NOW); + assertTransform(yearFmt, "2003-2005", "2003-" + NOW); } else { - assertUnchanged("2003"); - assertUnchanged("2003-2005"); + assertUnchanged(yearFmt, "2003"); + assertUnchanged(yearFmt, "2003-2005"); } - assertUnchanged(NOW); - assertTransform("", NOW); + assertUnchanged(yearFmt, NOW); + assertTransform(yearFmt, "", NOW); } @Test @@ -71,12 +84,48 @@ void normal() throws IOException { testSuiteUpdateWithLatest(false); } + @Test + void withYearStringFormat_defaultFormat() throws IOException { + setLicenseStep("licenseHeader('/** $YEAR */').yearStringFormat('%s')"); + testSuiteUpdateWithLatest(false, "%s"); + } + + @Test + void withYearStringFormat_spacesBefore() throws IOException { + setLicenseStep("licenseHeader('/** $YEAR */').yearStringFormat('%12s')"); + testSuiteUpdateWithLatest(false, "%12s"); + } + + @Test + void withYearStringFormat_spacesAfter() throws IOException { + setLicenseStep("licenseHeader('/** $YEAR */').yearStringFormat('%-12s')"); + testSuiteUpdateWithLatest(false, "%-12s"); + } + @Test void updateYearWithLatestTrue() throws IOException { setLicenseStep("licenseHeader('/** $YEAR */').updateYearWithLatest(true)"); testSuiteUpdateWithLatest(true); } + @Test + void updateYearWithLatestTrueAndWithYearStringFormat_defaultFormat() throws IOException { + setLicenseStep("licenseHeader('/** $YEAR */').updateYearWithLatest(true).yearStringFormat('%s')"); + testSuiteUpdateWithLatest(true, "%s"); + } + + @Test + void updateYearWithLatestTrueAndWithYearStringFormat_spacesAfter() throws IOException { + setLicenseStep("licenseHeader('/** $YEAR */').updateYearWithLatest(true).yearStringFormat('%-15s')"); + testSuiteUpdateWithLatest(true, "%-15s"); + } + + @Test + void updateYearWithLatestTrueAndWithYearStringFormat_spacesBefore() throws IOException { + setLicenseStep("licenseHeader('/** $YEAR */').updateYearWithLatest(true).yearStringFormat('%12s')"); + testSuiteUpdateWithLatest(true, "%12s"); + } + @Test void filterByContentPatternTest() throws IOException { setLicenseStep("licenseHeader('/** $YEAR */').onlyIfContentMatches('.+Test.+').updateYearWithLatest(true)"); 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 { From bd6b5a89b1dbf89b6be94dd2a915818d21a79ef4 Mon Sep 17 00:00:00 2001 From: Maciej Sitarz Date: Tue, 9 Jun 2026 15:17:28 +0200 Subject: [PATCH 02/10] Update 'Added' section in 'plugin-gradle/CHANGES.md' --- plugin-gradle/CHANGES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugin-gradle/CHANGES.md b/plugin-gradle/CHANGES.md index 47ce008f79..be002ad486 100644 --- a/plugin-gradle/CHANGES.md +++ b/plugin-gradle/CHANGES.md @@ -4,6 +4,8 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format ( ## [Unreleased] +### Added +- Add support for custom string format for license header copyright year via `yearStringFormat()`. ([#2965](https://github.com/diffplug/spotless/pull/2965)) ### Fixed - Prevent build caches from interfering when executing under the `-PspotlessIdeHook` mode. ([#2365](https://github.com/diffplug/spotless/issues/2365)) ### Changes From 0d013939f33ca8264dc0d8e83a1ac00fc15c3a07 Mon Sep 17 00:00:00 2001 From: Maciej Sitarz Date: Tue, 9 Jun 2026 15:18:05 +0200 Subject: [PATCH 03/10] spotlessApply --- .../java/com/diffplug/spotless/generic/LicenseHeaderStep.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 b55049dd92..4eba112ba2 100644 --- a/lib/src/main/java/com/diffplug/spotless/generic/LicenseHeaderStep.java +++ b/lib/src/main/java/com/diffplug/spotless/generic/LicenseHeaderStep.java @@ -364,11 +364,11 @@ private String replaceYear(String raw) { } } -// final String yearStr = formatYear(newYear); + // final String yearStr = formatYear(newYear); System.out.println("YEARFMT DEBUG: 2: beforeYear: '" + beforeYear + "'"); System.out.println("YEARFMT DEBUG: 2: newYear: '" + newYear + "'"); System.out.println("YEARFMT DEBUG: 2: afterYear: '" + afterYear + "'"); -// System.out.println("YEARFMT DEBUG: 2: yearStr: '" + yearStr + "'"); + // System.out.println("YEARFMT DEBUG: 2: yearStr: '" + yearStr + "'"); // return beforeYear + yearStr + afterYear + content; return beforeYear + newYear + afterYear + content; From ddb9fc494c747334786c714e8bda56e86a68bd84 Mon Sep 17 00:00:00 2001 From: Maciej Sitarz Date: Tue, 16 Jun 2026 12:38:16 +0200 Subject: [PATCH 04/10] Implementation improvements and code cleanup --- .../spotless/generic/LicenseHeaderStep.java | 63 ++++++------------- 1 file changed, 18 insertions(+), 45 deletions(-) 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 e7b520fc25..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 { @@ -243,7 +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 ? "%s" : yearStrFormat; + this.yearStrFormat = yearStrFormat == null ? DEFAULT_YEAR_STR_FORMAT : yearStrFormat; this.hasFileToken = FILENAME_PATTERN.matcher(licenseHeader).find(); Optional yearToken = getYearToken(licenseHeader); @@ -311,20 +312,19 @@ private String format(String raw, File file) { } private String addOrUpdateLicenseHeader(String raw, File file) { - System.out.println("YEARFMT DEBUG: addOrUpdateLicenseHeader() start"); - System.out.println("YEARFMT DEBUG: addOrUpdateLicenseHeader() raw: " + raw); - System.out.println("YEARFMT DEBUG: addOrUpdateLicenseHeader() file: " + file.getAbsolutePath()); raw = replaceYear(raw); return replaceFileName(raw, file); } - private String formatYearStr(String year) { - return yearStrFormat.formatted(year); + 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) { - System.out.println("YEARFMT DEBUG: replaceYear() start"); - System.out.println("YEARFMT DEBUG: replaceYear() raw: " + raw); Matcher contentMatcher = delimiterPattern.matcher(raw); if (!contentMatcher.find()) { throw new IllegalArgumentException("Unable to find delimiter regex " + delimiterPattern); @@ -335,15 +335,8 @@ private String replaceYear(String raw) { 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 - System.out.println("YEARFMT DEBUG: 0: raw: '" + raw + "'"); return raw; } else { - final String yearStr = formatYearStr(yearSepOrFull); - - System.out.println("YEARFMT DEBUG: 1: yearSepOrFull: '" + yearSepOrFull + "'"); - System.out.println("YEARFMT DEBUG: 1: yearStr: '" + yearStr + "'"); - - // return yearStr + content; // otherwise we'll have to add the header return yearSepOrFull + content; } @@ -355,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 @@ -363,27 +356,11 @@ private String replaceYear(String raw) { return raw; } } - - // final String yearStr = formatYear(newYear); - System.out.println("YEARFMT DEBUG: 2: beforeYear: '" + beforeYear + "'"); - System.out.println("YEARFMT DEBUG: 2: newYear: '" + newYear + "'"); - System.out.println("YEARFMT DEBUG: 2: afterYear: '" + afterYear + "'"); - // System.out.println("YEARFMT DEBUG: 2: yearStr: '" + yearStr + "'"); - - // return beforeYear + yearStr + afterYear + content; return beforeYear + newYear + afterYear + content; } else { - String newYear = calculateYearBySearching(raw.substring(0, contentMatcher.start())); - final String yearStr = formatYearStr(newYear); - - System.out.println("YEARFMT DEBUG: 3: beforeYear: '" + beforeYear + "'"); - System.out.println("YEARFMT DEBUG: 3: newYear: '" + newYear + "'"); - System.out.println("YEARFMT DEBUG: 3: afterYear: '" + afterYear + "'"); - System.out.println("YEARFMT DEBUG: 3: yearStr: '" + yearStr + "'"); - + String newYear = formatYearStr(calculateYearBySearching(raw.substring(0, contentMatcher.start()))); // at worst, we just say that it was made today - return beforeYear + yearStr + afterYear + content; - // return beforeYear + newYear + afterYear + content; + return beforeYear + newYear + afterYear + content; } } } @@ -393,30 +370,26 @@ private String replaceYear(String raw) { /** Calculates the year to inject. */ private String calculateYearExact(String parsedYear) { - System.out.println("YEARFMT DEBUG: calculateYearExact() start"); - System.out.println("YEARFMT DEBUG: calculateYearExact() parsedYear: " + parsedYear); - if (parsedYear.equals(formatYearStr(yearToday))) { - return formatYearStr(parsedYear); + if (parsedYear.equals(yearToday)) { + return parsedYear; } else if (YYYY.matcher(parsedYear).matches()) { if (updateYearWithLatest) { if (licenseHeaderWithRange) { - return formatYearStr(yearToday); + return yearToday; } else { - return formatYearStr(parsedYear + yearSepOrFull + yearToday); + return parsedYear + yearSepOrFull + yearToday; } } else { // it's already good as a single year - return formatYearStr(parsedYear); + return parsedYear; } } else { - return formatYearStr(calculateYearBySearching(parsedYear)); + return calculateYearBySearching(parsedYear); } } /** Searches the given string for YYYY, and uses that to determine the year range. */ private String calculateYearBySearching(String content) { - System.out.println("YEARFMT DEBUG: calculateYearBySearching() start"); - System.out.println("YEARFMT DEBUG: calculateYearBySearching() content: " + content); Matcher yearMatcher = YYYY.matcher(content); if (yearMatcher.find()) { String firstYear = yearMatcher.group(); @@ -492,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) { From aaa21c96cd508cf7bfb7fe556e5a73c15269efdd Mon Sep 17 00:00:00 2001 From: Maciej Sitarz Date: Tue, 16 Jun 2026 12:38:41 +0200 Subject: [PATCH 05/10] Test improvements --- .../gradle/spotless/LicenseHeaderTest.java | 44 ++++++++----------- 1 file changed, 19 insertions(+), 25 deletions(-) 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 ce2855b60c..c2e04281b3 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 @@ -104,47 +104,41 @@ void normal() throws IOException { } @Test - void withYearStringFormat_defaultFormat() throws IOException { - setLicenseStep("licenseHeader('/** $YEAR */').yearStringFormat('%s')"); - testSuiteUpdateWithLatest(false, "%s"); + void updateYearWithLatestTrue() throws IOException { + setLicenseStep("licenseHeader('/** $YEAR */').updateYearWithLatest(true)"); + testSuiteUpdateWithLatest(true); } @Test - void withYearStringFormat_spacesBefore() throws IOException { - setLicenseStep("licenseHeader('/** $YEAR */').yearStringFormat('%12s')"); - testSuiteUpdateWithLatest(false, "%12s"); - } + void withYearStringFormat() throws IOException { + // default format + setLicenseStep("licenseHeader('/** $YEAR */').yearStringFormat('%s')"); + testSuiteUpdateWithLatest(false, "%s"); - @Test - void withYearStringFormat_spacesAfter() throws IOException { + // fill with spaces before + setLicenseStep("licenseHeader('/** $YEAR */').yearStringFormat('%9s')"); + testSuiteUpdateWithLatest(false, "%9s"); + + // fill with spaces after setLicenseStep("licenseHeader('/** $YEAR */').yearStringFormat('%-12s')"); testSuiteUpdateWithLatest(false, "%-12s"); } @Test - void updateYearWithLatestTrue() throws IOException { - setLicenseStep("licenseHeader('/** $YEAR */').updateYearWithLatest(true)"); - testSuiteUpdateWithLatest(true); - } - - @Test - void updateYearWithLatestTrueAndWithYearStringFormat_defaultFormat() throws IOException { + void updateYearWithLatestTrue_withYearStringFormat() throws IOException { + // default format setLicenseStep("licenseHeader('/** $YEAR */').updateYearWithLatest(true).yearStringFormat('%s')"); testSuiteUpdateWithLatest(true, "%s"); - } - @Test - void updateYearWithLatestTrueAndWithYearStringFormat_spacesAfter() throws IOException { + // 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 updateYearWithLatestTrueAndWithYearStringFormat_spacesBefore() throws IOException { - setLicenseStep("licenseHeader('/** $YEAR */').updateYearWithLatest(true).yearStringFormat('%12s')"); - testSuiteUpdateWithLatest(true, "%12s"); - } - @Test void filterByContentPatternTest() throws IOException { setLicenseStep("licenseHeader('/** $YEAR */').onlyIfContentMatches('.+Test.+').updateYearWithLatest(true)"); From 7d16df92b41a5c3ace9a4b3399999cc0e049c1f9 Mon Sep 17 00:00:00 2001 From: Maciej Sitarz Date: Tue, 16 Jun 2026 12:41:56 +0200 Subject: [PATCH 06/10] Update plugin-maven/CHANGES.md --- plugin-maven/CHANGES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/plugin-maven/CHANGES.md b/plugin-maven/CHANGES.md index 4293a2cb4a..9f6f17ab8f 100644 --- a/plugin-maven/CHANGES.md +++ b/plugin-maven/CHANGES.md @@ -13,6 +13,7 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format ( ### Added - Add support for AsciiDoc formatting via `adocfmt`. ([#2960](https://github.com/diffplug/spotless/pull/2960)) - `` step now supports arbitrary formatter options via ``. ([#2968](https://github.com/diffplug/spotless/pull/2968)) +- Add support for custom string format for license header copyright year via `yearStringFormat()`. ([#2965](https://github.com/diffplug/spotless/pull/2965)) ## [3.6.0] - 2026-05-27 ### Added From 86e8bc7fec2a5931d50fcb3ece8a16297441b7df Mon Sep 17 00:00:00 2001 From: Maciej Sitarz Date: Tue, 16 Jun 2026 12:44:15 +0200 Subject: [PATCH 07/10] Remove 'java.io.IOException' never thrown exception from the method definition --- .../java/com/diffplug/gradle/spotless/LicenseHeaderTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c2e04281b3..bf707fd2c6 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'", From 09e74d392fce26454a917dfd6b4fb6fd9bc9c30e Mon Sep 17 00:00:00 2001 From: Maciej Sitarz Date: Tue, 16 Jun 2026 12:47:00 +0200 Subject: [PATCH 08/10] Use 'try-with-resources' for 'Git' object --- .../com/diffplug/gradle/spotless/LicenseHeaderTest.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 bf707fd2c6..bd51ab6b2f 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 @@ -174,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); + } } } From 5ceac1d0678f3ca49623f930a53b9cd6209326cd Mon Sep 17 00:00:00 2001 From: Maciej Sitarz Date: Tue, 16 Jun 2026 15:46:12 +0200 Subject: [PATCH 09/10] Test with format that will add spaces --- .../java/com/diffplug/gradle/spotless/LicenseHeaderTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 bd51ab6b2f..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 @@ -116,8 +116,8 @@ void withYearStringFormat() throws IOException { testSuiteUpdateWithLatest(false, "%s"); // fill with spaces before - setLicenseStep("licenseHeader('/** $YEAR */').yearStringFormat('%9s')"); - testSuiteUpdateWithLatest(false, "%9s"); + setLicenseStep("licenseHeader('/** $YEAR */').yearStringFormat('%11s')"); + testSuiteUpdateWithLatest(false, "%11s"); // fill with spaces after setLicenseStep("licenseHeader('/** $YEAR */').yearStringFormat('%-12s')"); From 9de4962d4c75d955d92ce6307c6cf71764338516 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 16 Jun 2026 10:06:41 -0700 Subject: [PATCH 10/10] Move #2965 changelog notes into the Unreleased section The 8.7.0/3.7.0 releases were published after this branch added its changelog entries, sweeping them into the now-versioned sections. Move them back into [Unreleased] since #2965 is not yet released. Co-Authored-By: Claude Opus 4.8 (1M context) --- plugin-gradle/CHANGES.md | 3 ++- plugin-maven/CHANGES.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/plugin-gradle/CHANGES.md b/plugin-gradle/CHANGES.md index 2b0924dcbd..d04b1fdbbd 100644 --- a/plugin-gradle/CHANGES.md +++ b/plugin-gradle/CHANGES.md @@ -3,13 +3,14 @@ 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 ### Added - Add support for AsciiDoc formatting via `adocfmt`. ([#2960](https://github.com/diffplug/spotless/pull/2960)) - `flexmark()` step now supports arbitrary formatter options via the `formatterOptions` map. ([#2968](https://github.com/diffplug/spotless/pull/2968)) -- Add support for custom string format for license header copyright year via `yearStringFormat()`. ([#2965](https://github.com/diffplug/spotless/pull/2965)) ### Fixed - `toggleOffOn` no longer disables lint-only steps such as `forbidWildcardImports`. ([#2962](https://github.com/diffplug/spotless/pull/2962)) - Prevent build caches from interfering when executing under the `-PspotlessIdeHook` mode. ([#2365](https://github.com/diffplug/spotless/issues/2365)) diff --git a/plugin-maven/CHANGES.md b/plugin-maven/CHANGES.md index 9f6f17ab8f..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 @@ -13,7 +15,6 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format ( ### Added - Add support for AsciiDoc formatting via `adocfmt`. ([#2960](https://github.com/diffplug/spotless/pull/2960)) - `` step now supports arbitrary formatter options via ``. ([#2968](https://github.com/diffplug/spotless/pull/2968)) -- Add support for custom string format for license header copyright year via `yearStringFormat()`. ([#2965](https://github.com/diffplug/spotless/pull/2965)) ## [3.6.0] - 2026-05-27 ### Added