Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make it easier to keep copyright headers up-to-date #593

Merged
merged 11 commits into from
Jun 3, 2020
4 changes: 4 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ This document is intended for Spotless developers.
We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (starting after version `1.27.0`).

## [Unreleased]
### Added
* `LicenseHeaderStep` now has an `updateYearWithLatest` parameter which can update copyright headers to today's date. ([#593](https://github.com/diffplug/spotless/pull/593))
* Parsing of existing years from headers is now more lenient.
* The `LicenseHeaderStep` constructor is now public, which allows capturing its state lazily, which is helpful for setting defaults based on `ratchetFrom`.

## [1.32.0] - 2020-06-01
### Added
Expand Down
125 changes: 79 additions & 46 deletions lib/src/main/java/com/diffplug/spotless/generic/LicenseHeaderStep.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
package com.diffplug.spotless.generic;

import java.io.File;
import java.io.IOException;
import java.io.Serializable;
import java.nio.charset.Charset;
import java.nio.file.Files;
Expand All @@ -28,6 +27,8 @@
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.annotation.Nullable;

import com.diffplug.spotless.FormatterStep;
import com.diffplug.spotless.LineEnding;
import com.diffplug.spotless.SerializableFileFilter;
Expand All @@ -43,14 +44,6 @@ public final class LicenseHeaderStep implements Serializable {
private static final SerializableFileFilter UNSUPPORTED_JVM_FILES_FILTER = SerializableFileFilter.skipFilesNamed(
"package-info.java", "package-info.groovy", "module-info.java");

private final String licenseHeader;
private final boolean hasYearToken;
private final Pattern delimiterPattern;
private final Pattern yearMatcherPattern;
private final String licenseHeaderBeforeYearToken;
private final String licenseHeaderAfterYearToken;
private final String licenseHeaderWithYearTokenReplaced;

/** Creates a FormatterStep which forces the start of each file to match a license header. */
public static FormatterStep createFromHeader(String licenseHeader, String delimiter) {
return createFromHeader(licenseHeader, delimiter, DEFAULT_YEAR_DELIMITER);
Expand Down Expand Up @@ -83,7 +76,7 @@ public static FormatterStep createFromFile(File licenseHeaderFile, Charset encod
Objects.requireNonNull(delimiter, "delimiter");
Objects.requireNonNull(yearSeparator, "yearSeparator");
return FormatterStep.createLazy(LicenseHeaderStep.NAME,
() -> new LicenseHeaderStep(licenseHeaderFile, encoding, delimiter, yearSeparator),
() -> new LicenseHeaderStep(new String(Files.readAllBytes(licenseHeaderFile.toPath()), encoding), delimiter, yearSeparator),
step -> step::format);
}

Expand All @@ -99,8 +92,19 @@ public static SerializableFileFilter unsupportedJvmFilesFilter() {
return UNSUPPORTED_JVM_FILES_FILTER;
}

/** The license that we'd like enforced. */
private final Pattern delimiterPattern;
private final String yearSepOrFull;
private final @Nullable String yearToday;
private final @Nullable String beforeYear;
private final @Nullable String afterYear;
private final boolean updateYearWithLatest;

private LicenseHeaderStep(String licenseHeader, String delimiter, String yearSeparator) {
this(licenseHeader, delimiter, yearSeparator, false);
}

/** The license that we'd like enforced. */
public LicenseHeaderStep(String licenseHeader, String delimiter, String yearSeparator, boolean updateYearWithLatest) {
if (delimiter.contains("\n")) {
throw new IllegalArgumentException("The delimiter must not contain any newlines.");
}
Expand All @@ -109,25 +113,27 @@ private LicenseHeaderStep(String licenseHeader, String delimiter, String yearSep
if (!licenseHeader.endsWith("\n")) {
licenseHeader = licenseHeader + "\n";
}
this.licenseHeader = licenseHeader;
this.delimiterPattern = Pattern.compile('^' + delimiter, Pattern.UNIX_LINES | Pattern.MULTILINE);

Optional<String> yearToken = getYearToken(licenseHeader);
this.hasYearToken = yearToken.isPresent();
if (hasYearToken) {
if (yearToken.isPresent()) {
yearToday = String.valueOf(YearMonth.now().getYear());
int yearTokenIndex = licenseHeader.indexOf(yearToken.get());
licenseHeaderBeforeYearToken = licenseHeader.substring(0, yearTokenIndex);
licenseHeaderAfterYearToken = licenseHeader.substring(yearTokenIndex + 5);
licenseHeaderWithYearTokenReplaced = licenseHeader.replace(yearToken.get(), String.valueOf(YearMonth.now().getYear()));
yearMatcherPattern = Pattern.compile("[0-9]{4}(" + Pattern.quote(yearSeparator) + "[0-9]{4})?");
beforeYear = licenseHeader.substring(0, yearTokenIndex);
afterYear = licenseHeader.substring(yearTokenIndex + yearToken.get().length());
yearSepOrFull = yearSeparator;
this.updateYearWithLatest = updateYearWithLatest;
} else {
licenseHeaderBeforeYearToken = null;
licenseHeaderAfterYearToken = null;
licenseHeaderWithYearTokenReplaced = null;
yearMatcherPattern = null;
yearToday = null;
beforeYear = null;
afterYear = null;
this.yearSepOrFull = licenseHeader;
this.updateYearWithLatest = false;
}
}

private static final Pattern patternYearSingle = Pattern.compile("[0-9]{4}");

/**
* Get the first place holder token being used in the
* license header for specifying the year
Expand All @@ -139,39 +145,66 @@ private static Optional<String> getYearToken(String licenseHeader) {
return YEAR_TOKENS.stream().filter(licenseHeader::contains).findFirst();
}

/** Reads the license file from the given file. */
private LicenseHeaderStep(File licenseFile, Charset encoding, String delimiter, String yearSeparator) throws IOException {
this(new String(Files.readAllBytes(licenseFile.toPath()), encoding), delimiter, yearSeparator);
}

/** Formats the given string. */
public String format(String raw) {
Matcher matcher = delimiterPattern.matcher(raw);
if (!matcher.find()) {
Matcher contentMatcher = delimiterPattern.matcher(raw);
if (!contentMatcher.find()) {
throw new IllegalArgumentException("Unable to find delimiter regex " + delimiterPattern);
} else {
if (hasYearToken) {
if (matchesLicenseWithYearToken(raw, matcher)) {
// that means we have the license like `licenseHeaderBeforeYearToken 1990-2015 licenseHeaderAfterYearToken`
if (yearToday == null) {
// the no year case is easy
if (contentMatcher.start() == yearSepOrFull.length() && raw.startsWith(yearSepOrFull)) {
// if no change is required, return the raw string without
// creating any other new strings for maximum performance
return raw;
} else {
return licenseHeaderWithYearTokenReplaced + raw.substring(matcher.start());
// otherwise we'll have to add the header
return yearSepOrFull + raw.substring(contentMatcher.start());
}
} else if (matcher.start() == licenseHeader.length() && raw.startsWith(licenseHeader)) {
// if no change is required, return the raw string without
// creating any other new strings for maximum performance
return raw;
} else {
// otherwise we'll have to add the header
return licenseHeader + raw.substring(matcher.start());
// the yes year case is a bit harder
int beforeYearIdx = raw.indexOf(beforeYear);
int afterYearIdx = raw.indexOf(afterYear, beforeYearIdx + beforeYear.length() + 1);

if (beforeYearIdx >= 0 && afterYearIdx >= 0 && afterYearIdx + afterYear.length() <= contentMatcher.start()) {
boolean noPadding = beforeYearIdx == 0 && afterYearIdx + afterYear.length() == contentMatcher.start(); // allows fastpath return raw
String parsedYear = raw.substring(beforeYearIdx + beforeYear.length(), afterYearIdx);
if (parsedYear.equals(yearToday)) {
// it's good as is!
return noPadding ? raw : beforeYear + yearToday + afterYear + raw.substring(contentMatcher.start());
} else if (patternYearSingle.matcher(parsedYear).matches()) {
if (updateYearWithLatest) {
// expand from `2004` to `2004-2020`
return beforeYear + parsedYear + yearSepOrFull + yearToday + afterYear + raw.substring(contentMatcher.start());
} else {
// it's already good as a single year
return noPadding ? raw : beforeYear + parsedYear + afterYear + raw.substring(contentMatcher.start());
}
} else {
Matcher yearMatcher = patternYearSingle.matcher(parsedYear);
if (yearMatcher.find()) {
String firstYear = yearMatcher.group();
String newYear;
String secondYear;
if (updateYearWithLatest) {
secondYear = firstYear.equals(yearToday) ? null : yearToday;
} else if (yearMatcher.find(yearMatcher.end() + 1)) {
secondYear = yearMatcher.group();
} else {
secondYear = null;
}
if (secondYear == null) {
newYear = firstYear;
} else {
newYear = firstYear + yearSepOrFull + secondYear;
}
return noPadding && newYear.equals(parsedYear) ? raw : beforeYear + newYear + afterYear + raw.substring(contentMatcher.start());
}
}
}
// at worst, we just say that it was made today
return beforeYear + yearToday + afterYear + raw.substring(contentMatcher.start());
}
}
}

private boolean matchesLicenseWithYearToken(String raw, Matcher matcher) {
int startOfTheSecondPart = raw.indexOf(licenseHeaderAfterYearToken);
return startOfTheSecondPart > licenseHeaderBeforeYearToken.length()
&& (raw.startsWith(licenseHeaderBeforeYearToken) && startOfTheSecondPart + licenseHeaderAfterYearToken.length() == matcher.start())
&& yearMatcherPattern.matcher(raw.substring(licenseHeaderBeforeYearToken.length(), startOfTheSecondPart)).matches();
}
}
6 changes: 6 additions & 0 deletions plugin-gradle/CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (starting after version `3.27.0`).

## [Unreleased]
### Added
* If you use `ratchetFrom` and `licenseHeader`, the year in your license header will now be automatically kept up-to-date for changed files. For example, if the current year is 2020: ([#593](https://github.com/diffplug/spotless/pull/593))
* `/** Copyright 2020 */` -> unchanged
* `/** Copyright 1990 */` -> `/** Copyright 1990-2020 */`
* `/** Copyright 1990-1993 */` -> `/** Copyright 1990-2020 */`
* You can disable this behavior with `licenseHeader(...).updateYearWithLatest(false)`, or you can enable it without using `ratchetFrom` by using `updateYearWithLatest(true)` (not recommended).
### Fixed
* `ratchetFrom` had a bug (now fixed) such that it reported all files outside the root directory as changed. ([#594](https://github.com/diffplug/spotless/pull/594))

Expand Down
29 changes: 6 additions & 23 deletions plugin-gradle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -543,32 +543,15 @@ to true.

## License header options

If the license header (specified with `licenseHeader` or `licenseHeaderFile`) contains `$YEAR` or `$today.year`, then that token will be replaced with the current 4-digit year. For example, if Spotless is launched in 2017, then `/* Licensed under Apache-2.0 $YEAR. */` will produce `/* Licensed under Apache-2.0 2017. */`
If the license header (specified with `licenseHeader` or `licenseHeaderFile`) contains `$YEAR` or `$today.year`, then that token will be replaced with the current 4-digit year. For example, if Spotless is launched in 2020, then `/* Licensed under Apache-2.0 $YEAR. */` will produce `/* Licensed under Apache-2.0 2020. */`

The `licenseHeader` and `licenseHeaderFile` steps will generate license headers with automatic years according to the following rules:
* A generated license header will be updated with the current year when
* the generated license header is missing
* the generated license header is not formatted correctly
* A generated license header will _not_ be updated when
* a single year is already present, e.g.
`/* Licensed under Apache-2.0 1990. */`
* a year range is already present, e.g.
`/* Licensed under Apache-2.0 1990-2003. */`
* the `$YEAR` token is otherwise missing
Once a file's license header has a valid year, whether it is a year (`2020`) or a year range (`2017-2020`), it will not be changed. If you want the date to be updated when it changes, enable the [`ratchetFrom` functionality](#ratchet), and the year will be automatically set to today's year according to the following table (assuming the current year is 2020):

The separator for the year range defaults to the hyphen character, e.g `1990-2003`, but can be customized with the `yearSeparator` property.
* No license header -> `2020`
* `2017` -> `2017-2020`
* `2017-2019` -> `2017-2020`

For instance, the following configuration treats `1990, 2003` as a valid year range.

```gradle
spotless {
java {
licenseHeader('Licensed under Apache-2.0 $YEAR').yearSeparator(', ')
}
}
```

To update the copyright notice only for changed files, use the [`ratchetFrom` functionality](#ratchet).
See the [javadoc](https://javadoc.io/static/com.diffplug.spotless/spotless-plugin-gradle/4.1.0/com/diffplug/gradle/spotless/FormatExtension.LicenseHeaderConfig.html) for a complete listing of options.

<a name="custom"></a>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@
import static com.diffplug.gradle.spotless.PluginGradlePreconditions.requireElementsNonNull;

import java.io.File;
import java.io.IOException;
import java.io.Serializable;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.util.*;
import java.util.stream.Stream;

Expand Down Expand Up @@ -406,9 +408,15 @@ public void indentWithTabs() {
addStep(IndentStep.Type.TAB.create());
}

/**
* Created by {@link FormatExtension#licenseHeader(String, String)} or {@link FormatExtension#licenseHeaderFile(Object, String)}.
* For most language-specific formats (e.g. java, scala, etc.) you can omit the second `delimiter` argument, because it is supplied
* automatically ({@link HasBuiltinDelimiterForLicense}).
*/
public abstract class LicenseHeaderConfig {
String delimiter;
String yearSeparator = LicenseHeaderStep.defaultYearDelimiter();
Boolean updateYearWithLatest = null;

public LicenseHeaderConfig(String delimiter) {
this.delimiter = Objects.requireNonNull(delimiter, "delimiter");
Expand All @@ -434,36 +442,54 @@ public LicenseHeaderConfig yearSeparator(String yearSeparator) {
return this;
}

abstract FormatterStep createStep();
}
/**
* @param updateYearWithLatest
* Will turn `2004` into `2004-2020`, and `2004-2019` into `2004-2020`
* Default value is false, unless {@link SpotlessExtension#ratchetFrom(String)} is used, in which case default value is true.
*/
public LicenseHeaderConfig updateYearWithLatest(boolean overwriteYearLatest) {
this.updateYearWithLatest = overwriteYearLatest;
replaceStep(createStep());
return this;
}

public class LicenseStringHeaderConfig extends LicenseHeaderConfig {
protected abstract String licenseHeader() throws IOException;

FormatterStep createStep() {
return FormatterStep.createLazy(LicenseHeaderStep.name(), () -> {
// by default, we should update the year if the user is using ratchetFrom
boolean updateYear = updateYearWithLatest == null ? FormatExtension.this.root.getRatchetFrom() != null : updateYearWithLatest;
return new LicenseHeaderStep(licenseHeader(), delimiter, yearSeparator, updateYear);
}, step -> step::format);
}
}

private class LicenseStringHeaderConfig extends LicenseHeaderConfig {
private String header;

LicenseStringHeaderConfig(String delimiter, String header) {
super(delimiter);
this.header = Objects.requireNonNull(header, "header");
}

FormatterStep createStep() {
return LicenseHeaderStep.createFromHeader(header, delimiter, yearSeparator);
@Override
protected String licenseHeader() {
return header;
}
}

public class LicenseFileHeaderConfig extends LicenseHeaderConfig {

private class LicenseFileHeaderConfig extends LicenseHeaderConfig {
private Object headerFile;

LicenseFileHeaderConfig(String delimiter, Object headerFile) {
super(delimiter);
this.headerFile = Objects.requireNonNull(headerFile, "headerFile");
}

FormatterStep createStep() {
return LicenseHeaderStep
.createFromFile(getProject().file(headerFile), getEncoding(), delimiter,
yearSeparator);
@Override
protected String licenseHeader() throws IOException {
byte[] content = Files.readAllBytes(getProject().file(headerFile).toPath());
return new String(content, getEncoding());
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
import static org.assertj.core.api.Assertions.assertThat;

import java.io.IOException;
import java.time.YearMonth;

import org.gradle.testkit.runner.BuildResult;
import org.junit.Test;
Expand Down Expand Up @@ -193,7 +192,7 @@ public void testWithNonStandardYearSeparator() throws IOException {
matcher.startsWith("// License Header 2012, 2014");
});
assertFile("src/main/kotlin/test2.kt").matches(matcher -> {
matcher.startsWith(HEADER_WITH_YEAR.replace("$YEAR", String.valueOf(YearMonth.now().getYear())));
matcher.startsWith("// License Header 2012, 2014");
});
}

Expand Down Expand Up @@ -223,7 +222,7 @@ public void testWithNonStandardYearSeparatorKtfmt() throws IOException {
matcher.startsWith("// License Header 2012, 2014");
});
assertFile("src/main/kotlin/test2.kt").matches(matcher -> {
matcher.startsWith(HEADER_WITH_YEAR.replace("$YEAR", String.valueOf(YearMonth.now().getYear())));
matcher.startsWith("// License Header 2012, 2014");
});
}
}