From 73f37d307b5e9f1c0eb1fc9372dfb7c78d5808be Mon Sep 17 00:00:00 2001 From: Maciej Sitarz Date: Tue, 9 Jun 2026 14:35:27 +0200 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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;