Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -59,24 +60,26 @@ public static LicenseHeaderStep headerDelimiter(String header, String delimiter)
}

public static LicenseHeaderStep headerDelimiter(ThrowingEx.Supplier<String> 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;
final @Nullable String contentPattern;
final ThrowingEx.Supplier<String> headerLazy;
final String delimiter;
final String yearSeparator;
final String yearStrFmt;
final Supplier<YearMode> yearMode;
final @Nullable String skipLinesMatching;

private LicenseHeaderStep(@Nullable String name, @Nullable String contentPattern, ThrowingEx.Supplier<String> headerLazy, String delimiter, String yearSeparator, Supplier<YearMode> yearMode, @Nullable String skipLinesMatching) {
private LicenseHeaderStep(@Nullable String name, @Nullable String contentPattern, ThrowingEx.Supplier<String> headerLazy, String delimiter, String yearSeparator, Supplier<YearMode> 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);
}

Expand All @@ -85,39 +88,43 @@ 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) {
return withHeaderLazy(() -> header);
}

public LicenseHeaderStep withHeaderLazy(ThrowingEx.Supplier<String> 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) {
return withYearModeLazy(() -> yearMode);
}

public LicenseHeaderStep withYearModeLazy(Supplier<YearMode> 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<Runtime, FormatterFunc> {
Expand All @@ -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, () -> {
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
Expand All @@ -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.");
}
Expand All @@ -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<String> yearToken = getYearToken(licenseHeader);
Expand Down Expand Up @@ -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()) {
Expand All @@ -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
Expand All @@ -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;
}
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions plugin-gradle/CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand All @@ -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
Expand All @@ -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)");
Expand Down Expand Up @@ -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);
}
}
}
2 changes: 2 additions & 0 deletions plugin-maven/CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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();
Expand All @@ -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 {
Expand Down
Loading